Compare commits

...
167 Commits
Author SHA1 Message Date
dni ⚡andGitHub 723d8ba98f chore: update to v1.0.0-rc2 (#2705) 2024-09-24 11:48:28 +02:00
daee2b3418 Check for theme params on the URL (#2678)
---------

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

---------

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

---------

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

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

---------

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

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

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

I don't see why this exists

---------

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

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

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

Wrong string case search

Closes #1599

* optimize the code

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

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

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

* chore: bundle

* fixup!

---------

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

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

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

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

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

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

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

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

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

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

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

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

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

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

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

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

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

* fetch account info when possible

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

* fundle

* make format

* fix formatting

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

* format

* fix lint

* format

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

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

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

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

* vlad picks
* refactor get_placeholder

---------

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

---------

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

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

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

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

* Update lnbits/helpers.py

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

* chore: code format

---------

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

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

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

---------

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

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

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

* chore: make bundle

* Blink review 01 (#2575)

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

* feat: set default value for `blink_api_endpoint`

* fix: raise if HTTP call failed

* refactor: move private method to the bottom

* refactor: make `wallet_id` a property

* fix: key mapping for attribute

* chore: fix `mypy`

* chore: fix `make check`

* refactor: extract query strings

* refactor: extract `BlinkGrafqlQueries` class

* chore: code clean-up

* chore: add `try-catch`

* refactor: extract `tx_query`

* chore: format grapfhql queries

* fix: set funding source class

* chore: `make format`

* fix: test by following the other patterns

* Update docs/guide/wallets.md

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

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

* feat: add websocket connection to blink

* feat: close websocket on shutdown

* feat: add `blink_ws_endpoint` to the UI

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

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

* fix: remove test env file

---------

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

* no assert in api

---------

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

websocket weren't working with that conf

* cleanup

---------

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

* refactor: extract `create_user_account` in services

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

* feat: add `details_link` field for extension

* feat: show info icon if `details_link` present

* feat: add extension details endpoint

* feat: first details page

* feat: carousel working

* feat: full screen

* fix: layout

* fix: repo site

* fix: release icon

* fix: repo link

* feat: terms and conditions partial

* chore: fix typing

* fix: info icon layout

* chore: add try-catch

* feat: layout improvements

* feat: add video link

* fix: show terms and conditions

* chore: code format

* feat: add `details_link`

* fix: github release details

* feat: add close button

* chore: code clean-up

* chore: revert some changes

* feat: i18n

* chore: `make bundle`

* chore: make bundle

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

fixing bug in normalize_enpoints

* Update lnbits/wallets/base.py

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

---------

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

---------

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


---------

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

* Sort funding options select alphabetically

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

- Support description, restricted to 128 characters
- Support descriptionHash

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

* feat: add buttons

* feat: persist `pay to enable` changes

* fix: do not disable extension on upgrade

* fix: show releases tab first

* feat: extract `enableExtension` logic

* refactor: rename routes

* feat: show dialog for paying extension

* feat: create invoice to enable

* refactor: extract enable/disable extension logic

* feat: add extra info to UserExtensions

* feat: check payment for extension enable

* fix: parsing

* feat: admins must not pay

* fix: code checks

* fix: test

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

* feat: add `get_user_extensions `

* feat: return explicit `requiresPayment`

* feat: add `isPaymentRequired` to extension list

* fix: `paid_to_enable` status

* fix: ui layout

* feat: show QR Code

* feat: wait for invoice to be paid

* test: removed deprecated test and dead code

* feat: add re-check button

* refactor: rename paths for endpoints

* feat: i18n

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

* test: fix listener

* fix: rebase errors

* chore: update bundle

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

* fix: active extension loading from file system

* chore: temp commit

* fix: premature optimisation

* chore: make check

* refactor: remove extracted logic

* chore: code format

* fix: enable by default after install

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

* chore: code format

* fix: better error code

* fix: check for stop function before invoking

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

* refactor: return 402 Requires Payment

* chore: more typing

* chore: temp checkout different branch for tests

* fix: too much typing

* fix: remove try-except

* fix: typo

* fix: manual format

* fix: merge issue

* remove this line

---------

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

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

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

* chore(i18n): update strings using the AI tool
2024-05-22 14:18:23 +03:00
dni ⚡andGitHub 5b056ce07e feat: update latest docker tag on release (#2528) 2024-05-22 13:47:41 +03:00
Vlad Stananddni ⚡ 44b458ebb8 [fix] check user extension access (#2519)
* feat: check user extension access
* fix: handle upgraded extensions
2024-05-22 11:10:35 +02:00
Vlad Stan d4da96597e chore: project version bump 2024-05-22 12:04:56 +03:00
Vlad StanandGitHub 6a0b645316 hotfix: check for lnbits_site_description setting (#2527)
* fix: make `lnbits_site_description` optional again
2024-05-22 10:53:34 +02:00
dni ⚡andGitHub 35bb3cc94b fix: topup wallet was showing NaN (#2504)
* fix: topup wallet was showing NaN
2024-05-16 15:31:24 +02:00
Gonçalo ValérioandGitHub 7a6c3646fb Fix: Wrong expiration date in invoice details (#2506)
* invoices without the x tag, should be assumed to take 3600 seconds to expire

* update bolt11 package and use built in method to calculate the invoice expiry date

* fix linter errors
2024-05-16 13:48:58 +01:00
b84161c49d [chore] Update legend to demo (#2505)
* fix: update `legend.lnbits.com` to `demo.lnbits.com`
* Update docs/guide/admin_ui.md
* fix: docker image name
* fix: donation links

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-05-16 10:05:22 +02:00
dni ⚡andGitHub 63ce506d29 fix: add cln unspecified error code bolt11 error to errorcodes (#2503)
{'code': -32602, 'message': 'Invalid bolt11: Prefix bc is not for regtest'}```
2024-05-16 09:59:54 +02:00
Vlad StanandGitHub 5114bd4a47 fix: link to demo server 2024-05-15 11:57:24 +03:00
dni ⚡andGitHub 05a244d8fd fix: refresh payments on payment success (#2502)
* fix: refresh payments on payment
2024-05-14 19:22:14 +03:00
dni ⚡andGitHub 019995078c fix: init wallet balance (#2501) 2024-05-14 18:56:57 +03:00
Vlad StanandGitHub f37cb6481c fix: copy invoice (#2500)
* fix: copy invoice
* chore: add mixins
* chore: make bundle
2024-05-14 17:55:45 +02:00
dni ⚡andGitHub 365f9a3923 fix: mobileSimple in paymentlist (#2498) 2024-05-14 13:57:04 +02:00
dni ⚡andGitHub bb4dd4fe35 fix: payment list currency (#2496)
* fix: payment list currency
2024-05-14 13:49:00 +02:00
Vlad StanandGitHub d1ae531750 fix: add methods back (#2495) 2024-05-14 13:48:33 +02:00
8ee2948f71 fix: payment list updates (#2493)
* fix: payment list updates

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-05-14 13:06:44 +02:00
ArcandGitHub ab3fe79a7e typo: fix opensats badge (#2494) 2024-05-14 12:35:39 +02:00
dni ⚡andGitHub 32596758cc fix: show proper total balances fix cleanups (#2490)
payments are not deleted oif we delete a wallets, so to get a accurate
total representation of the lnbits balance we need to create the
balances view based on the wallets table, not payments, else deleted
balances will still show up.

2nd, delete_unused_wallets and delete_accounts was never working if
because they never got an updated_at time, so i just check if its null
else i check to timedelta on created_at
2024-05-13 18:01:53 +01:00
dni ⚡andGitHub a5623ef7c3 feat: add payments table to user manager (#2491)
* feat: add payments table to user manager

refactor payments table and payment chart into components and add them
to usermanager

* bundle
2024-05-13 18:01:01 +01:00
9933484558 refactor: get_balance_delta and use pydantic model for openapi docs (#2492)
* refactor: `get_balance_delta` and use pydantic model for openapi docs

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-05-13 17:59:29 +02:00
dni ⚡andGitHub 78fc28558c refactor: catch payment and invoice error at faspi exceptionhandler level (#2484)
refactor exceptionhandlers into `exception.py` also now always throw
payment error when pay_invoice and invoice errors when create_invoice.

return a status flag with the detailed error message. with a 520
response
2024-05-13 17:58:48 +02:00
dni ⚡andGitHub 1e752dc3d2 test: services create and pay invoice (#2452)
* test: services create and pay invoice
* add more tests
* check with fundingsource
* check status
2024-05-13 16:55:38 +02:00
dni ⚡andGitHub 6730c6ed67 refactor: fix duplicate keychecker (#2339)
* refactor: fix duplicate keychecker

- refactor KeyChecker to be more approachable
- only 1 sql query needed even if you use `get_key_type`
- rename `WalletType` to `KeyType` wallet type was misleading

fix test

sorting

* fixup!

* revert 404
2024-05-13 15:26:25 +01:00
Vlad StanandGitHub 9f8942a921 chore: sort funding sources (#2489) 2024-05-10 17:45:51 +02:00
dni ⚡andGitHub 33c68065d5 fix: usermanager visible for everyone (#2488)
* fix: usermanager sidemenu
was missed by merge
2024-05-10 13:24:51 +02:00
9ca14f200d feat: usermanager (#2139)
* feat: usermanager

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-05-10 12:06:46 +02:00
eae5002b69 fix: pay invoice status (#2481)
* fix: rest `pay_invoice` pending instead of failed
* fix: rpc `pay_invoice` pending instead of failed
* fix: return "failed" value for payment
* fix: handle failed status for LNbits funding source
* chore: `phoenixd` todo
* test: fix condition
* fix: wait for payment status to be updated
* fix: fail payment when explicit status provided

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-05-10 11:49:50 +02:00
dni ⚡andGitHub b9e62bfceb refactor: move logger function from app.py to utils/logger.py (#2454)
* refactor: move logger function from `app.py` to `utils/logger.py`

just some simply refactoring to clean up app.py

* while true
2024-05-09 17:51:18 +01:00
dni ⚡andGitHub f60122c64a feat: update docker image to debian 12 (bookwork) (#2486)
postgres client install was failing for bullseye, i think its time
2024-05-09 18:26:45 +02:00
dni ⚡andGitHub c030ccc4f8 fix: no need to create fake admin (#2485)
already is done inside regtest
2024-05-09 16:56:11 +02:00
0076a85fdb fix: typo in phoenixd wallet (#2473)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-05-03 18:49:09 +02:00
c3d37a460c Improved customisable homepage and added badge (#2474)
* Improved customisable homepage and added badge

* Added filled to styling of drop down

* format

* Wrong model

* lint hack

* Update .env.example

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>

* reverted

* Spelling

* More explicit

* format

* Added if for badge

* spellling

* Fix for None

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-04-30 08:08:57 +01:00
c04c13b2f8 feat: phoenixd wallet integration (#2362)
* phoenixd integration
---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-04-26 10:18:38 +02:00
dni ⚡andGitHub 4b4bed59cd feat: corelightning maxfee and custom pay command (#2464)
* feat: corelightning `maxfee` and custom pay command
we should use maxfee instead of calculating the ratio and pass it
through.
also make it possible to run a custom pay command
* change for cln rest aswell
2024-04-25 12:54:00 +02:00
dni ⚡andGitHub 4a0fb59461 chore: use prettier for all of the codebase (#2466)
* chore: use prettier for all of the codebase
we only checked `lnbits` dir before
2024-04-25 11:13:08 +02:00
Pavol RusnakandGitHub f5293ca645 fix: compatibility with Python 3.12 (#2463)
by updating grpc a uvloop dependencies to latest
2024-04-24 08:39:41 +02:00
Vlad StanandGitHub b2ff2d8cee [test] add tests for lnbits funding source (#2460) 2024-04-24 09:31:23 +03:00
Vlad StanandGitHub 8d3b156738 [test] add tests for eclair funding source (#2459)
* fix: test data

* test: add `status` tests

* refactor: try-catch

* test: create invoice tests

* test: add first `payinvoice` test

* test: add pay_invoice error test

* feat: allow more test options

* test: add pending tests

* fix: make check

* test: add, pending no fee

* fix: make consistent with other API calls

* test: more assertions

* test: add pending

* test: first payment status test

* test: pending status

* refactor: remove duplicate code

* refactor: rename field

* chore: code format

* chore: uniform
2024-04-23 16:18:52 +01:00
dni ⚡andPavol Rusnak 00f39a2007 test: add unit for fee_reserve and service_fee
:)

sorting
2024-04-22 11:38:24 +02:00
Vlad StanandGitHub 4ac30116a9 feat: add settings.lnbits_running (#2450)
* feat: add `settings.lnbits_runing `
2024-04-22 12:33:53 +03:00
dni ⚡andGitHub e91096c535 feat: remove magic argument parser from lnbits command (#2448)
got the idea from: https://github.com/lnbits/lnbits/issues/2447
arguments it should be explicity allowed with `click` and a description
should be added like here.
2024-04-19 13:23:56 +02:00
dni ⚡andGitHub e607ab7a3e test: restructure tests (#2444)
unit, api, wallets
* only run test-api for migration
2024-04-19 13:22:06 +02:00
Vlad StanandGitHub 67fdb77339 test: unit tests for lndrpc (#2442) 2024-04-19 13:21:21 +02:00
Pavol Rusnak 4f118c5f98 chore: make bundle 2024-04-18 15:44:17 +02:00
Pavol Rusnak 33ace85f7d i18n: refresh translations using AI script 2024-04-18 15:44:17 +02:00
Pavol Rusnak 33c50100ab fix: small update to i18n-ai-tool system prompt 2024-04-18 15:44:17 +02:00
782cbfc77f fix: balances view on use non deleted wallets (#2385)
* fix: balances view on use non deleted wallets

closes #2224

* fixup! fix: balances view on use non deleted wallets

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-04-18 11:49:40 +01:00
dni ⚡andGitHub bbfc301440 fix: broken lnurl_callback (#2445)
* fix: broken lnurl_callback
2024-04-18 12:16:00 +02:00
dni ⚡andGitHub 98ec59df96 feat: adhere to ruff's B rules (#2423)
* feat: adhere to ruff's `B` rules
last of the ruff checks.
closes #2308
* B904
* B008
* B005
* B025
* cleanup on fake
2024-04-17 13:11:51 +02:00
dni ⚡andGitHub e13a37c193 FEAT: add PYPI python package release workflow on tag (#1628)
* add pypi worflow, pyproject types, package metadata, ignore python package build, docker build fails if poetry uses readme for python package

* add pypi to release
2024-04-17 10:53:57 +02:00
dni ⚡andGitHub 0c3aabf77a feat: catch_everything_and_restart print name of the task (#2417)
remove type from `Coroutine` from the create_tasks
2024-04-17 10:51:07 +02:00
d9880c4de8 Clarified top-up success msg (#2381)
* Update admin_api.py
Common misconception is that the top up related to the funds on the funding source. 
Success msg  extended with info that correlated funds on funding source are needed and the amount is virtual until fitting.

* chore: code format
* feat: customise top-up message
* refactor: move the `Quasar.Notify` to `components.js`
* refactor: use `this.$q.notify` instead of `Quasar.Notify.create`

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-04-17 09:55:57 +02:00
ArcandGitHub 0a4eb78ef0 Fixes ad issue on homepage + readme badges (#2422)
Fixes broken ad layout on homepage
* Removed telegram link
2024-04-17 08:54:48 +02:00
dni ⚡andGitHub daa1b5a313 chore: adhere to ruff's RUF rules, 2nd try (#2420)
* chore: adhere to ruff's `RUF` rules, 2nd try
closes #2382
2024-04-17 07:36:22 +02:00
ArcandGitHub d78f6a1f9e Added extra default ads to support project (#2421) 2024-04-16 16:26:04 +02:00
dni ⚡andGitHub 839fe8b96d feat: add PEP561 marker for types (#2415)
tasks.py:8: error: Skipping analyzing "lnbits.tasks": module is
installed, but missing library stubs or py.typed marker
[import-untyped]

https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
2024-04-16 16:16:45 +02:00
25661ddff5 chore: remove lnurl wallet and withdraw feature (#2293)
* chore: remove lnurl wallet and withdraw feature
this feature is undocumented and the code is very outdated. i don't think it is worth to keep.
looking at the `/lnurlwallet` endpoint for example, it creates a new user and wallet without even checking if the creation of users is allowed
* remove lnurl callback

---------

Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-04-16 14:10:32 +02:00
dni ⚡andGitHub 55eb3be5d5 feat: make workflow reuseable for external repo (#2419)
* feat: make workflow reuseable for external repo

* fixup!
2024-04-16 10:43:53 +01:00
dni ⚡andGitHub 0714570242 fix: ruff linting broke (#2418)
fix linting
2024-04-16 08:34:33 +02:00
69ce0e565b [test] create unit-test framework for RPC wallets (#2396)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-04-15 17:24:28 +02:00
dni ⚡andPavol Rusnak b145bff566 chore: adhere to ruff's UP
basically use `list` and `type` instead of `List` and `Type`

this is save to use for python3.9 and has been deprecated. also has some
performance drawbacks.
read more here: https://docs.astral.sh/ruff/rules/non-pep585-annotation/
2024-04-15 13:38:04 +02:00
dni ⚡andGitHub a158056b99 chore: enable migration tests again (#2414)
PR has been merged. https://github.com/lnbits/lnbits-extensions/pull/300
2024-04-15 11:12:03 +02:00
Vlad StanandGitHub e8479941c8 fix: check installed extensions, not available ones (#2413) 2024-04-15 10:21:15 +03:00
6d5ad9e229 chore: adhere to ruff's "N" rules (#2377)
* chore: adhere to ruff's "N" rules

WARN: reinstall failing extensions!

bunch of more consistent variable naming. inspired by this issue.
https://github.com/lnbits/lnbits/issues/2308

* fixup! chore: adhere to ruff's "N" rules
* rename to funding_source
* skip jmeter

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-04-15 09:02:21 +02:00
dni ⚡andGitHub 055426ab53 chore: prepare version update to 0.12.6 (#2407)
needed in preparation of https://github.com/lnbits/lnbits/pull/2377
2024-04-12 17:29:08 +02:00
dni ⚡andGitHub 4bafe97167 fix: fastapi status import (#2408)
should use `from http import HTTPStatus`
2024-04-12 15:56:54 +02:00
dni ⚡andGitHub 8aef6cd416 chore: update prettier (#2405)
update to new prettier version with some new formatting
2024-04-12 09:00:31 +02:00
adb8f9bdec feat: add funding_source_max_retries env setting (#2404)
* feat: add `funding_source_max_retries` env setting

* feat: default to zero retries

* feat: exponential retry time increase

* chore: Let's use the same value as the default

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

* feat: using 0.25 leads to less awkward numbers

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

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-04-11 17:29:25 +01:00
226 changed files with 42895 additions and 17977 deletions
+2
View File
@@ -3,12 +3,14 @@ data
docker docker
docs docs
tests tests
node_modules
lnbits/static/css/* lnbits/static/css/*
lnbits/static/bundle.js lnbits/static/bundle.js
lnbits/static/bundle.css lnbits/static/bundle.css
*.md *.md
!README.md
*.log *.log
.env .env
+37 -3
View File
@@ -15,7 +15,7 @@ LNBITS_ADMIN_UI=false
# Change theme # Change theme
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
@@ -28,12 +28,15 @@ PORT=5000
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet" # LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file. # just so you can see the UI before dealing with this file.
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources # Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=3600 LIGHTNING_INVOICE_EXPIRY=3600
@@ -55,7 +58,7 @@ CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXS
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert" CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
# LnbitsWallet # LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_ENDPOINT=https://demo.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet # LndWallet
@@ -84,10 +87,25 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/ ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet # ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/ ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
# OpenNodeWallet # OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY OPENNODE_KEY=OPENNODE_ADMIN_KEY
@@ -100,11 +118,22 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283 ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet # LnTipsWallet
# Enter /api in LightningTipBot to get your key # Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
###################################### ######################################
####### Auth Configurations ########## ####### Auth Configurations ##########
###################################### ######################################
@@ -155,6 +184,8 @@ LNBITS_ADMIN_USERS=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin" LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
# Start LNbits core only. The extensions are not loaded. # Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true # LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
@@ -175,6 +206,9 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# LNBITS_AD_SPACE_TITLE="Supported by" # LNBITS_AD_SPACE_TITLE="Supported by"
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor # csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png" # LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
# LNBITS_CUSTOM_BADGE_COLOR="warning"
# Hides wallet api, extensions can choose to honor # Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false LNBITS_HIDE_API=false
+1 -1
View File
@@ -1 +1 @@
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+4 -1
View File
@@ -46,7 +46,10 @@ runs:
- name: Install the project dependencies - name: Install the project dependencies
shell: bash shell: bash
run: poetry install run: |
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }} - name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }} if: ${{ (inputs.npm == 'true') }}
+31 -1
View File
@@ -12,7 +12,7 @@ jobs:
lint: lint:
uses: ./.github/workflows/lint.yml uses: ./.github/workflows/lint.yml
tests: test-api:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
@@ -20,6 +20,35 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-wallets:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/wallets"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-unit:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/unit"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }} db-url: ${{ matrix.db-url }}
secrets: secrets:
@@ -48,6 +77,7 @@ jobs:
python-version: ["3.9"] python-version: ["3.9"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"] backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with: with:
custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }} backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets: secrets:
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ${{ matrix.os-version }} runs-on: ${{ matrix.os-version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/prepare - uses: lnbits/lnbits/.github/actions/prepare@dev
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
+3 -4
View File
@@ -28,11 +28,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24 - uses: cachix/install-nix-action@v27
with: with:
nix_path: nixpkgs=channel:nixos-23.11 nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v13 - uses: cachix/cachix-action@v15
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
with: with:
name: lnbits name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+14 -22
View File
@@ -3,8 +3,9 @@ name: regtest
on: on:
workflow_call: workflow_call:
inputs: inputs:
make: custom-pytest:
default: test description: "Custom pytest arguments"
required: true
type: string type: string
python-version: python-version:
default: "3.9" default: "3.9"
@@ -26,19 +27,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx - name: docker build
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }} if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/setup-buildx-action@v3 run: |
docker build -t lnbits/lnbits .
- name: Build and push
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: lnbits/lnbits:latest
cache-from: type=registry,ref=lnbits/lnbits:latest
cache-to: type=inline
- name: Setup Regtest - name: Setup Regtest
run: | run: |
@@ -52,10 +44,6 @@ jobs:
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- name: Create fake admin
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
- name: Run pytest - name: Run pytest
uses: pavelzw/pytest-action@v2 uses: pavelzw/pytest-action@v2
env: env:
@@ -76,15 +64,14 @@ jobs:
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee" LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082 ECLAIR_URL: http://127.0.0.1:8082
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
LNBITS_DATA_FOLDER: "./tests/data"
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
DEBUG: true DEBUG: true
with: with:
verbose: false verbose: true
job-summary: true job-summary: true
emoji: false emoji: false
click-to-expand: false click-to-expand: true
custom-pytest: poetry run pytest custom-pytest: ${{ inputs.custom-pytest }}
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}" report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@@ -93,3 +80,8 @@ jobs:
file: ./coverage.xml file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
verbose: true verbose: true
- name: docker lnbits logs
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: |
docker logs lnbits-lnbits-1
+44
View File
@@ -0,0 +1,44 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --prerelease
docker:
needs: [ release ]
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 }}
+23
View File
@@ -10,6 +10,7 @@ permissions:
contents: write contents: write
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -29,3 +30,25 @@ jobs:
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
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 }}
+7 -5
View File
@@ -3,6 +3,10 @@ name: tests
on: on:
workflow_call: workflow_call:
inputs: inputs:
custom-pytest:
description: "Custom pytest arguments"
required: true
type: string
python-version: python-version:
default: "3.9" default: "3.9"
type: string type: string
@@ -50,16 +54,14 @@ jobs:
env: env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }} LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: FakeWallet LNBITS_BACKEND_WALLET_CLASS: FakeWallet
FAKE_WALLET_SECRET: "ToTheMoon1"
LNBITS_DATA_FOLDER: "./tests/data"
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
DEBUG: true DEBUG: true
with: with:
verbose: false verbose: true
job-summary: true job-summary: true
emoji: false emoji: false
click-to-expand: false click-to-expand: true
custom-pytest: poetry run pytest custom-pytest: ${{ inputs.custom-pytest }}
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})" report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
+6 -2
View File
@@ -35,6 +35,7 @@ __bundle__
coverage.xml coverage.xml
node_modules node_modules
lnbits/static/bundle.js lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css lnbits/static/bundle.css
lnbits/static/bundle.min.js.old lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old lnbits/static/bundle.min.css.old
@@ -49,5 +50,8 @@ fly.toml
lnbits-backup.zip lnbits-backup.zip
# Ignore extensions (post installable extension PR) # Ignore extensions (post installable extension PR)
extensions /lnbits/extensions
upgrades/ /upgrades/
# builded python package
dist
+1 -1
View File
@@ -23,7 +23,7 @@ repos:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a' rev: "v4.0.0-alpha.8"
hooks: hooks:
- id: prettier - id: prettier
types_or: [css, javascript, html, json] types_or: [css, javascript, html, json]
+5
View File
@@ -10,4 +10,9 @@
**/lnbits/static/vendor **/lnbits/static/vendor
**/lnbits/static/bundle.* **/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/* **/lnbits/static/css/*
flake.lock
.venv
+34 -8
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim-bullseye FROM python:3.10-slim-bookworm AS builder
RUN apt-get clean RUN apt-get clean
RUN apt-get update RUN apt-get update
@@ -7,18 +7,44 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN mkdir data
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
RUN poetry install --only main
FROM python:3.10-slim-bookworm
# needed for backups postgresql-client version 14 (pg_dump) # needed for backups postgresql-client version 14 (pg_dump)
RUN apt-get install -y apt-utils wget RUN apt-get update && apt-get -y upgrade && \
RUN echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list apt-get -y install gnupg2 curl lsb-release && \
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
RUN apt-get update curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
RUN apt-get install -y postgresql-client-14 apt-get update && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
COPY . . COPY . .
COPY --from=builder /app/.venv .venv
RUN mkdir data
RUN poetry install --only main RUN poetry install --only main
+28 -16
View File
@@ -6,8 +6,10 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier checkbundle check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier: prettier:
poetry run ./node_modules/.bin/prettier --write lnbits poetry run ./node_modules/.bin/prettier --write .
pyright: pyright:
poetry run ./node_modules/.bin/pyright poetry run ./node_modules/.bin/pyright
@@ -25,7 +27,7 @@ checkruff:
poetry run ruff check . poetry run ruff check .
checkprettier: checkprettier:
poetry run ./node_modules/.bin/prettier --check lnbits poetry run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
poetry run black --check . poetry run black --check .
@@ -36,23 +38,36 @@ checkeditorconfig:
dev: dev:
poetry run lnbits --reload poetry run lnbits --reload
test: test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest poetry run pytest tests/wallets
test-real-wallet: 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" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest poetry run pytest tests/regtest
test-migration: test-migration:
LNBITS_ADMIN_UI=True \ LNBITS_ADMIN_UI=True \
make test make test-api
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=5002 \ PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
@@ -88,24 +103,21 @@ sass:
bundle: bundle:
npm install npm install
npm run sass npm run bundle
npm run vendor_copy
npm run vendor_json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle: checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1 diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1 diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK" @echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
+6 -12
View File
@@ -1,22 +1,16 @@
<picture > <picture >
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px"> <img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
</picture> </picture>
<b>(BETA)</b> ![phase: beta](https://img.shields.io/badge/phase-beta-C41E3A) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-08A04B) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
[![license-badge]](LICENSE) [![docs-badge]][docs]
![Lightning network wallet](https://i.imgur.com/DeIiO0y.png) ![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
# The world's most powerful suite of bitcoin tools. # The world's most powerful suite of bitcoin tools.
## Run for yourself, for others, or as part of a stack. ## Run for yourself, for others, or as part of a stack.
(Join us on [https://t.me/lnbits](https://t.me/lnbits)) LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
LNbits is beta, for responsible disclosure of any concerns please contact an admin in [https://t.me/lnbits](https://t.me/lnbits)
LNbits is a Python server that sits on top of any funding source. It can be used as: LNbits is a Python server that sits on top of any funding source. It can be used as:
@@ -36,7 +30,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
## Running LNbits ## Running LNbits
Test on our demo server [legend.lnbits.com](https://legend.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr. Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup. See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
@@ -50,7 +44,7 @@ LNbits is packaged with tools to help manage funds, such as a table of transacti
Extend YOUR LNbits to meet YOUR needs. Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions. All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px"> <img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
@@ -74,7 +68,7 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
## Tip us ## Tip us
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
[docs]: https://github.com/lnbits/lnbits/wiki [docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
+2 -4
View File
@@ -5,8 +5,6 @@ title: API reference
nav_order: 3 nav_order: 3
--- ---
# API reference
API reference [Swagger Docs](https://demo.lnbits.com/docs)
=============
[Swagger Docs](https://legend.lnbits.com/docs)
+24 -11
View File
@@ -5,30 +5,39 @@ nav_order: 4
has_children: true has_children: true
--- ---
# For developers
For developers
==============
Thanks for contributing :) Thanks for contributing :)
# Run
Run 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:
This starts the lnbits uvicorn server
```bash ```bash
poetry run lnbits poetry run lnbits
``` ```
This starts the lnbits uvicorn with hot reloading. Or you can use the following to start uvicorn with hot reloading enabled:
```bash ```bash
make dev make dev
# or # or
poetry run lnbits --reload poetry run lnbits --reload
``` ```
Precommit hooks 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. This ensures that all commits adhere to the formatting and linting rules.
@@ -36,31 +45,35 @@ This ensures that all commits adhere to the formatting and linting rules.
make install-pre-commit-hook make install-pre-commit-hook
``` ```
Tests # Tests
=====
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies: This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash ```bash
poetry install poetry install
npm i npm i
``` ```
Then to run the tests: Then to run the tests:
```bash ```bash
make test make test
``` ```
Run formatting: Run formatting:
```bash ```bash
make format make format
``` ```
Run mypy checks: Run mypy checks:
```bash ```bash
poetry run mypy poetry run mypy
``` ```
Run everything: Run everything:
```bash ```bash
make all make all
``` ```
+19 -14
View File
@@ -5,11 +5,10 @@ title: Making extensions
nav_order: 2 nav_order: 2
--- ---
# Extension set up
Extension set up
=================
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`: Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
```sh ```sh
cd [my-working-folder] cd [my-working-folder]
git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those. git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those.
@@ -18,6 +17,7 @@ rm -rf .git/
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'. find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
mv templates/example templates/mysuperplugin # Rename templates folder. mv templates/example templates/mysuperplugin # Rename templates folder.
``` ```
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs. - if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
1. Edit `manifest.json` and change the organisation name to your GitHub username. 1. Edit `manifest.json` and change the organisation name to your GitHub username.
@@ -30,17 +30,15 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
1. ... 1. ...
1. Profit!!! 1. Profit!!!
Extension structure explained ## Extension structure explained
-----------------------------
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools. - views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this. - views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits. - migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't. ... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
## Adding new dependencies
Adding new dependencies
-----------------------
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others. DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
@@ -53,9 +51,7 @@ $ poetry add <package>
**But we need an extra step to make sure LNbits doesn't break in production.** **But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`. Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration
SQLite to PostgreSQL migration
-----------------------
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend. LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
@@ -64,22 +60,31 @@ LNbits currently supports SQLite and PostgreSQL databases. There is a migration
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR. `mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
### running migration locally ### running migration locally
you will need a running postgres database you will need a running postgres database
#### create lnbits user for migration database #### create lnbits user for migration database
```console ```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'" sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
``` ```
#### create migration database #### create migration database
```console ```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'" sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
``` ```
#### run the migration #### run the migration
```console ```console
make test-migration make test-migration
``` ```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'" sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again #### clean migration database afterwards, fails if you try again
```console ```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'" sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
``` ```
+30 -27
View File
@@ -1,29 +1,32 @@
<html> <html>
<head> <head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com --> <!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script> <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/> <link
<title>My New API</title> rel="stylesheet"
</head> type="text/css"
<body> href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"
<div id="swagger-ui"></div> <!-- Div to hold the UI component --> />
<script> <title>My New API</title>
window.onload = function () { </head>
// Begin Swagger UI call region <body>
const ui = SwaggerUIBundle({ <div id="swagger-ui"></div>
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo <!-- Div to hold the UI component -->
dom_id: '#swagger-ui', <script>
deepLinking: true, window.onload = function () {
presets: [ // Begin Swagger UI call region
SwaggerUIBundle.presets.apis, const ui = SwaggerUIBundle({
SwaggerUIBundle.SwaggerUIStandalonePreset url: 'https://demo.lnbits.com/openapi.json', //Location of Open API spec in the repo
], dom_id: '#swagger-ui',
plugins: [ deepLinking: true,
SwaggerUIBundle.plugins.DownloadUrl presets: [
], SwaggerUIBundle.presets.apis,
}) SwaggerUIBundle.SwaggerUIStandalonePreset
window.ui = ui ],
} plugins: [SwaggerUIBundle.plugins.DownloadUrl]
</script> })
</body> window.ui = ui
}
</script>
</body>
</html> </html>
+2 -5
View File
@@ -5,14 +5,11 @@ title: Websockets
nav_order: 2 nav_order: 2
--- ---
# Websockets
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client. `websockets` are a great way to add a two way instant data channel between server and client.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`. LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://demo.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://demo.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
Example vue-js function for listening to the websocket: Example vue-js function for listening to the websocket:
+14 -12
View File
@@ -4,17 +4,15 @@ title: Admin UI
nav_order: 4 nav_order: 4
--- ---
# Admin UI
Admin UI
========
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend. The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true` It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
the settings are initialized and saved to the database and will be used from there as long the UI is enabled. the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
From there on the settings from the database are used. From there on the settings from the database are used.
# Super User
Super User
==========
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs. With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources. The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
@@ -29,48 +27,52 @@ We also added a decorator for the API routes to check for super user.
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings` There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
# Admin Users
Admin Users
===========
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`. Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
# Allowed Users
Allowed Users
=============
environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend. By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
Setting this environment variable also disables account creation. Setting this environment variable also disables account creation.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false` Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
# How to activate
How to activate
=============
``` ```
$ sudo systemctl stop lnbits.service $ sudo systemctl stop lnbits.service
$ cd ~/lnbits-legend $ cd ~/lnbits
$ sudo nano .env $ sudo nano .env
``` ```
-> set: `LNBITS_ADMIN_UI=true` -> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window Now start LNbits once in the terminal window
``` ```
$ poetry run lnbits $ poetry run lnbits
``` ```
You can now `cat` the Super User ID: You can now `cat` the Super User ID:
``` ```
$ cat data/.super_user $ cat data/.super_user
123de4bfdddddbbeb48c8bc8382fe123 123de4bfdddddbbeb48c8bc8382fe123
``` ```
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain. You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions` After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee. Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
Do not forget Do not forget
``` ```
sudo systemctl start lnbits.service sudo systemctl start lnbits.service
``` ```
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid. A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
+32 -31
View File
@@ -10,16 +10,16 @@ Go to `Manage Server` > `Server` > `Extensions Manifests`
![image](https://user-images.githubusercontent.com/2951406/213494038-e8152d8e-61f2-4cb7-8b5f-361fc3f9a31f.png) ![image](https://user-images.githubusercontent.com/2951406/213494038-e8152d8e-61f2-4cb7-8b5f-361fc3f9a31f.png)
An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions). An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions).
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section. Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section.
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one). **LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
> **Warning** > **Warning**
> Extensions can have bugs or malicious code, be careful what you install!! > Extensions can have bugs or malicious code, be careful what you install!!
## Install New Extension ## Install New Extension
Only administrator users can install or upgrade extensions. Only administrator users can install or upgrade extensions.
Go to `Manage Extensions` > `Add Remove Extensions` Go to `Manage Extensions` > `Add Remove Extensions`
@@ -45,13 +45,12 @@ Select the version to be installed (usually the last one) and click `Install`. O
> >
> For Explicit Release: the order of the releases is the one in the "extensions" object > For Explicit Release: the order of the releases is the one in the "extensions" object
The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state. The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state.
Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it. Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it.
## Uninstall Extension ## Uninstall Extension
On the `Install` page click `Manage` for the extension you want to uninstall: On the `Install` page click `Manage` for the extension you want to uninstall:
![image](https://user-images.githubusercontent.com/2951406/213653194-32cbb1da-dcc8-43cf-8a82-1ec5d2d3dc16.png) ![image](https://user-images.githubusercontent.com/2951406/213653194-32cbb1da-dcc8-43cf-8a82-1ec5d2d3dc16.png)
@@ -65,6 +64,7 @@ Users will no longer be able to access the extension.
> The database for the extension is not removed. If the extension is re-installed later, the data will be accessible. > The database for the extension is not removed. If the extension is re-installed later, the data will be accessible.
## Manifest File ## Manifest File
The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form: The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form:
```json ```json
@@ -77,30 +77,32 @@ The manifest file is just a `JSON` file that lists a collection of extensions th
There are two ways to specify installable extensions: There are two ways to specify installable extensions:
### Explicit Release ### Explicit Release
It goes under the `extensions` object and it is of the form: It goes under the `extensions` object and it is of the form:
```json ```json
{ {
"id": "lnurlp", "id": "lnurlp",
"name": "LNURL Pay Links", "name": "LNURL Pay Links",
"version": 1, "version": 1,
"shortDescription": "Upgrade to version 111111111", "shortDescription": "Upgrade to version 111111111",
"icon": "receipt", "icon": "receipt",
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>", "details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip", "archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a", "hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1", "htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
"infoNotification": "This is a very old version", "infoNotification": "This is a very old version",
"dependencies": ["other-ext-id"] "dependencies": ["other-ext-id"]
} }
``` ```
<details><summary>Fields Detailed Description</summary> <details><summary>Fields Detailed Description</summary>
| Field | Type | | Description | | Field | Type | | Description |
|----------------------|---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | -------------------- | ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. | | id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
| name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. | | name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. |
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. | | version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
| shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. | | shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. |
| icon | string | optional | quasar valid icon name | | icon | string | optional | quasar valid icon name |
| details | string (html) | optional | Details about this particular release | | details | string (html) | optional | Details about this particular release |
@@ -109,31 +111,30 @@ It goes under the `extensions` object and it is of the form:
| htmlUrl | string | optional | Link to the extension home page. | | htmlUrl | string | optional | Link to the extension home page. |
| infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. | | infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. |
| criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. | | criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. |
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed. | dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed. |
</details> </details>
This mode has the advantage of strictly specifying what releases of an extension can be installed. This mode has the advantage of strictly specifying what releases of an extension can be installed.
### GitHub Repository ### GitHub Repository
It goes under the `repos` object and it is of the form: It goes under the `repos` object and it is of the form:
```json ```json
{ {
"id": "withdraw", "id": "withdraw",
"organisation": "lnbits", "organisation": "lnbits",
"repository": "withdraw-extension" "repository": "withdraw-extension"
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|--------------|--------|-------------------------------------------------------| | ------------ | ------ | --------------------------------------------------------------------------------------------------- |
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. | | id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
| organisation | string | The GitHub organisation (eg: `lnbits`) | | organisation | string | The GitHub organisation (eg: `lnbits`) |
| repository | string | The GitHub repository name (eg: `withdraw-extension`) | | repository | string | The GitHub repository name (eg: `withdraw-extension`) |
The admin user will see all releases from the Github repository: The admin user will see all releases from the Github repository:
![image](https://user-images.githubusercontent.com/2951406/213508934-11de5ae5-2045-471c-854b-94b6acbf4434.png) ![image](https://user-images.githubusercontent.com/2951406/213508934-11de5ae5-2045-471c-854b-94b6acbf4434.png)
+6 -3
View File
@@ -4,10 +4,10 @@ title: FAQ
nav_order: 5 nav_order: 5
--- ---
# FAQ - Frequently Asked Questions # FAQ - Frequently Asked Questions
## Install options ## Install options
<ul><p>LNbits is not a node management software but a ⚡️LN only accounting system on top of a funding source.</p> <ul><p>LNbits is not a node management software but a ⚡️LN only accounting system on top of a funding source.</p>
<details><summary>Funding my LNbits wallet from my node it doesn't work.</summary> <details><summary>Funding my LNbits wallet from my node it doesn't work.</summary>
@@ -54,6 +54,7 @@ allow-self-payment=1
</ul> </ul>
## Troubleshooting ## Troubleshooting
<ul><details><summary>Message "https error" or network error" when scanning a LNbits QR</summary> <ul><details><summary>Message "https error" or network error" when scanning a LNbits QR</summary>
<p>Bad news, this is a routing error that might have quite a lot of reasons. Let´s try a few of the most possible problems and their solutions. First choose your setup</p> <p>Bad news, this is a routing error that might have quite a lot of reasons. Let´s try a few of the most possible problems and their solutions. First choose your setup</p>
<ul> <ul>
@@ -83,7 +84,7 @@ allow-self-payment=1
<details><summary>Wallet-URL deleted, are my funds safu ?</summary> <details><summary>Wallet-URL deleted, are my funds safu ?</summary>
<ul> <ul>
<li> <li>
<details><summary>Wallet on demo server legend.lnbits</summary> <details><summary>Wallet on demo server demo.lnbits.com</summary>
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place. LNbits CANNOT help you to recover them when lost.</p> <p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place. LNbits CANNOT help you to recover them when lost.</p>
</details> </details>
</li> </li>
@@ -155,7 +156,6 @@ allow-self-payment=1
</p> </p>
</details> </details>
<details><summary>How can I use a LNbits lndhub account in other wallet apps?</summary> <details><summary>How can I use a LNbits lndhub account in other wallet apps?</summary>
<p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the <a href="https://github.com/lnbits/lndhub">LNDHUB extension</a>.</p> <p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the <a href="https://github.com/lnbits/lndhub">LNDHUB extension</a>.</p>
<p>Then open the LNDHUB extension, choose the wallet you want to use and scan the QR code you want to use: "admin" or "invoice only", depending on the security level you want for that wallet.</p> <p>Then open the LNDHUB extension, choose the wallet you want to use and scan the QR code you want to use: "admin" or "invoice only", depending on the security level you want for that wallet.</p>
@@ -166,6 +166,7 @@ allow-self-payment=1
</ul> </ul>
## Building hardware tools ## Building hardware tools
<ul> <p>LNbits has all sorts of open APIs and tools to program and connect to a lot of different devices for a gazillion of use-cases. Let us know what you did with it ! Come to the <a href="https://t.me/makerbits">Makerbits Telegram Group</a> if you are interested in building or if you need help with a project - we got you!</p> <ul> <p>LNbits has all sorts of open APIs and tools to program and connect to a lot of different devices for a gazillion of use-cases. Let us know what you did with it ! Come to the <a href="https://t.me/makerbits">Makerbits Telegram Group</a> if you are interested in building or if you need help with a project - we got you!</p>
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary> <details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
@@ -216,6 +217,7 @@ allow-self-payment=1
</ul> </ul>
## Use cases of LNbits ## Use cases of LNbits
<ul><details><summary>Merchant</summary> <ul><details><summary>Merchant</summary>
<p>LNbits is a powerful solution for merchants, due to the easy setup with various extensions, that can be used for many scenarios.</p> <p>LNbits is a powerful solution for merchants, due to the easy setup with various extensions, that can be used for many scenarios.</p>
<p><a href="https://darthcoin.substack.com/p/lnbits-for-small-merchants">Here is an overview of the LNbits tools available for a small restaurant as well as a hotel</a></p> <p><a href="https://darthcoin.substack.com/p/lnbits-for-small-merchants">Here is an overview of the LNbits tools available for a small restaurant as well as a hotel</a></p>
@@ -262,6 +264,7 @@ allow-self-payment=1
</ul> </ul>
## Developing for LNbits ## Developing for LNbits
<ul> <ul>
<li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li> <li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li>
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul> <li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
+24 -4
View File
@@ -1,19 +1,23 @@
## Defining a route with path parameters ## Defining a route with path parameters
**old:** **old:**
```python ```python
# with <> # with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"]) @offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
``` ```
**new:** **new:**
```python ```python
# with curly braces: {} # with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}") @offlineshop_ext.get("/lnurl/{item_id}")
``` ```
## Check if a user exists and access user object ## Check if a user exists and access user object
**old:** **old:**
```python ```python
# decorators # decorators
@check_user_exists() @check_user_exists()
@@ -24,14 +28,18 @@ async def do_routing_stuff():
**new:** **new:**
If user doesn't exist, `Depends(check_user_exists)` will raise an exception. If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
If user exists, `user` will be the user object If user exists, `user` will be the user object
```python ```python
# depends calls # depends calls
@core_html_routes.get("/my_route") @core_html_routes.get("/my_route")
async def extensions(user: User = Depends(check_user_exists)): async def extensions(user: User = Depends(check_user_exists)):
pass pass
``` ```
## Returning data from API calls ## Returning data from API calls
**old:** **old:**
```python ```python
return ( return (
{ {
@@ -42,9 +50,11 @@ return (
HTTPStatus.OK, HTTPStatus.OK,
) )
``` ```
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:** **new:**
```python ```python
return { return {
"id": wallet.wallet.id, "id": wallet.wallet.id,
@@ -54,6 +64,7 @@ return {
``` ```
To change the default HTTPStatus, add it to the path decorator To change the default HTTPStatus, add it to the path decorator
```python ```python
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED) @core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
async def payments(): async def payments():
@@ -61,7 +72,9 @@ async def payments():
``` ```
## Raise exceptions ## Raise exceptions
**old:** **old:**
```python ```python
return ( return (
{"message": f"Failed to connect to {domain}."}, {"message": f"Failed to connect to {domain}."},
@@ -74,6 +87,7 @@ abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
**new:** **new:**
Raise an exception to return a status code other than the default status code. Raise an exception to return a status code other than the default status code.
```python ```python
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@@ -82,7 +96,9 @@ raise HTTPException(
``` ```
## Extensions ## Extensions
**old:** **old:**
```python ```python
from quart import Blueprint from quart import Blueprint
@@ -92,6 +108,7 @@ amilk_ext: Blueprint = Blueprint(
``` ```
**new:** **new:**
```python ```python
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
@@ -114,9 +131,12 @@ offlineshop_rndr = template_renderer([
``` ```
## Possible optimizations ## Possible optimizations
### Use Redis as a cache server ### Use Redis as a cache server
Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key. Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key.
Example: Example:
* Get transactions for a wallet ID
* User data for a user id - Get transactions for a wallet ID
* Wallet data for a Admin / Invoice key - User data for a user id
- Wallet data for a Admin / Invoice key
+51 -28
View File
@@ -6,25 +6,22 @@ nav_order: 2
# Basic installation # Basic installation
You can choose between four package managers, `poetry` and `nix` The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below). Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
## Option 1 (recommended): poetry ## Option 1 (recommended): Poetry
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX) It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
Make sure you have Python 3.9 or 3.10 installed.
### Verify Python version
### install python on ubuntu
```sh ```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
``` ```
### install poetry ### Install Poetry
```sh ```sh
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command # Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
@@ -36,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
git checkout main git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main poetry install --only main
mkdir data
cp .env.example .env cp .env.example .env
# set funding source amongst other options # set funding source amongst other options
nano .env nano .env
@@ -56,6 +48,7 @@ poetry run lnbits
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output # adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too. # Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
#### Updating the server #### Updating the server
``` ```
@@ -67,7 +60,19 @@ poetry install --only main
# Start LNbits with `poetry run lnbits` # Start LNbits with `poetry run lnbits`
``` ```
## Option 2: Nix ## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@@ -97,7 +102,7 @@ nix run
Ideally you would set the environment via the `.env` file, Ideally you would set the environment via the `.env` file,
but you can also set the env variables or pass command line arguments: but you can also set the env variables or pass command line arguments:
``` sh ```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI. # .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
@@ -105,16 +110,19 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
## Option 3: Docker ## Option 4: Docker
use latest version from docker hub use latest version from docker hub
```sh ```sh
docker pull lnbits/lnbits docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
build the image yourself build the image yourself
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
@@ -124,7 +132,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
## Option 4: Fly.io ## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use. Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
@@ -157,7 +165,7 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings. You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`. Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`. Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
@@ -254,11 +262,10 @@ You might also need to install additional packages or perform additional setup s
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment. Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
# Additional guides # Additional guides
## SQLite to PostgreSQL migration ## SQLite to PostgreSQL migration
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale. If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works: There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
@@ -280,7 +287,6 @@ make migration
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly. Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd service ## LNbits as a systemd service
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content: Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
@@ -440,6 +446,15 @@ server {
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
listen [::]:443 ssl; listen [::]:443 ssl;
listen 443 ssl; listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem; ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
@@ -457,28 +472,36 @@ service restart nginx
``` ```
## Using https without reverse proxy ## Using https without reverse proxy
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network. The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text. We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
#### Install mkcert #### Install mkcert
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert). You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu: Install mkcert on Ubuntu:
```sh ```sh
sudo apt install libnss3-tools sudo apt install libnss3-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64 chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
``` ```
#### Create certificate #### Create certificate
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux: To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh ```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
``` ```
This will create two new files (`key.pem` and `cert.pem `). This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)): Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh ```sh
# add your local IP (192.x.x.x) as well if you want to use it in your local network # add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1 mkcert localhost 127.0.0.1 ::1
@@ -490,7 +513,6 @@ You can then pass the certificate files to uvicorn when you start LNbits:
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
``` ```
## LNbits running on Umbrel behind Tor ## LNbits running on Umbrel behind Tor
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it. If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
@@ -502,7 +524,7 @@ To install using docker you first need to build the docker image as:
``` ```
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
docker build -t lnbits-legend . docker build -t lnbits/lnbits .
``` ```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
@@ -514,6 +536,7 @@ cp <lnbits_repo>/.env.example .env
and change the configuration in `.env` as required. and change the configuration in `.env` as required.
Then create the data directory Then create the data directory
``` ```
mkdir data mkdir data
``` ```
@@ -521,7 +544,7 @@ mkdir data
Then the image can be run as: Then the image can be run as:
``` ```
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
``` ```
Finally you can access your lnbits on your machine at port 5000. Finally you can access your lnbits on your machine at port 5000.
+51 -4
View File
@@ -4,15 +4,12 @@ title: Backend wallets
nav_order: 3 nav_order: 3
--- ---
# Backend wallets
Backend wallets
===============
LNbits can run on top of many Lightning Network funding sources with more being added regularly. LNbits can run on top of many Lightning Network funding sources with more being added regularly.
A backend wallet can be configured using the following LNbits environment variables: A backend wallet can be configured using the following LNbits environment variables:
### CoreLightning ### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -79,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/ - `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey - `OPENNODE_KEY`: opennodeAdminApiKey
### Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
### Alby ### Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
@@ -87,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/ - `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken - `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD ### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -95,6 +113,35 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/ - `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `ZBD_API_KEY`: ZBDApiKey - `ZBD_API_KEY`: ZBDApiKey
### Phoenixd
For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
- `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 Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
+7 -11
View File
@@ -4,21 +4,17 @@ title: Users Guide
nav_order: 1 nav_order: 1
--- ---
# LNbits, free and open-source Lightning Network wallet/accounts system
LNbits, free and open-source Lightning Network wallet/accounts system
=====================================================================
LNbits is a very simple Python application that sits on top of any funding source, and can be used as: LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet - Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring Lightning Network functionality via LNbits extension framework - Extendable platform for exploring Lightning Network functionality via LNbits extension framework
* Part of a development stack via LNbits API - Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme - Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations - Instant wallet for LN demonstrations
## LNbits as an account system
LNbits as an account system
---------------------------
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
export to csv + more to come... export to csv + more to come...
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1694529238, "lastModified": 1710146030,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -26,11 +26,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1698974481, "lastModified": 1703863825,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=", "narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nix-github-actions", "repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2", "rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -41,16 +41,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1702233072, "lastModified": 1723938990,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=", "narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348", "rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "nixos-23.11", "ref": "nixos-24.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1702334837, "lastModified": 1724134185,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=", "narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community", "owner": "nix-community",
"repo": "poetry2nix", "repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99", "rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -122,11 +122,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1699786194, "lastModified": 1719749022,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=", "narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1", "rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github" "type": "github"
}, },
"original": { "original": {
+2 -8
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system"; description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11"; nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = { poetry2nix = {
url = "github:nix-community/poetry2nix"; url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@@ -30,16 +30,10 @@
meta.rev = self.dirtyRev or self.rev; meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName; meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: { overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; }; protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; }; ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; }; wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
}); });
}; };
}); });
+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
+67 -257
View File
@@ -1,35 +1,35 @@
import asyncio import asyncio
import glob import glob
import importlib import importlib
import logging
import os import os
import shutil import shutil
import sys import sys
import traceback
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import Callable, List, Optional
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from loguru import logger from loguru import logger
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse
from lnbits.core.crud import get_dbversions, get_installed_extensions from lnbits.core.crud import (
add_installed_extension,
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.extensions.extension_manager import deactivate_extension
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services import websocketUpdater
from lnbits.core.tasks import ( # watchdog_task from lnbits.core.tasks import ( # watchdog_task
killswitch_task, killswitch_task,
wait_for_paid_invoices, wait_for_paid_invoices,
) )
from lnbits.exceptions import register_exception_handlers
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import ( from lnbits.tasks import (
cancel_all_tasks, cancel_all_tasks,
@@ -37,21 +37,18 @@ from lnbits.tasks import (
register_invoice_listener, register_invoice_listener,
) )
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
from lnbits.wallets import get_wallet_class, set_wallet_class from lnbits.utils.logger import (
configure_logger,
initialize_server_websocket_logger,
log_server_info,
)
from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases from .commands import migrate_databases
from .core import init_core_routers from .core import init_core_routers
from .core.db import core_app_extra from .core.db import core_app_extra
from .core.extensions.models import Extension, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .helpers import template_renderer
from .middleware import ( from .middleware import (
CustomGZipMiddleware, CustomGZipMiddleware,
ExtensionsRedirectMiddleware, ExtensionsRedirectMiddleware,
@@ -63,12 +60,14 @@ from .middleware import (
from .requestvars import g from .requestvars import g
from .tasks import ( from .tasks import (
check_pending_payments, check_pending_payments,
create_task,
internal_invoice_listener, internal_invoice_listener,
invoice_listener, invoice_listener,
) )
async def startup(app: FastAPI): async def startup(app: FastAPI):
settings.lnbits_running = True
# wait till migration is done # wait till migration is done
await migrate_databases() await migrate_databases()
@@ -81,7 +80,7 @@ async def startup(app: FastAPI):
# initialize WALLET # initialize WALLET
try: try:
set_wallet_class() set_funding_source()
except Exception as e: except Exception as e:
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}") logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
set_void_wallet_class() set_void_wallet_class()
@@ -92,26 +91,21 @@ async def startup(app: FastAPI):
# register core routes # register core routes
init_core_routers(app) init_core_routers(app)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_installed_extensions(app)
register_all_ext_routes(app)
if settings.lnbits_admin_ui:
initialize_server_logger()
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks(app)
async def shutdown(): async def shutdown():
logger.warning("LNbits shutting down...")
settings.lnbits_running = False
# shutdown event # shutdown event
cancel_all_tasks() cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems # wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
WALLET = get_wallet_class() funding_source = get_funding_source()
await WALLET.cleanup() await funding_source.cleanup()
@asynccontextmanager @asynccontextmanager
@@ -138,8 +132,8 @@ def create_app() -> FastAPI:
) )
# Allow registering new extensions routes without direct access to the `app` object # Allow registering new extensions routes without direct access to the `app` object
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app)) core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app)) core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files # register static files
static_path = Path("lnbits", "static") static_path = Path("lnbits", "static")
@@ -177,38 +171,39 @@ def create_app() -> FastAPI:
async def check_funding_source() -> None: async def check_funding_source() -> None:
funding_source = get_funding_source()
WALLET = get_wallet_class() max_retries = settings.funding_source_max_retries
sleep_time = 5
max_retries = 5
retry_counter = 0 retry_counter = 0
while True: while settings.lnbits_running:
try: try:
logger.info(f"Connecting to backend {WALLET.__class__.__name__}...") logger.info(f"Connecting to backend {funding_source.__class__.__name__}...")
error_message, balance = await WALLET.status() error_message, balance = await funding_source.status()
if not error_message: if not error_message:
retry_counter = 0 retry_counter = 0
logger.success( logger.success(
f"✔️ Backend {WALLET.__class__.__name__} connected " f"✔️ Backend {funding_source.__class__.__name__} connected "
f"and with a balance of {balance} msat." f"and with a balance of {balance} msat."
) )
break break
logger.error( logger.error(
f"The backend for {WALLET.__class__.__name__} isn't " f"The backend for {funding_source.__class__.__name__} isn't "
f"working properly: '{error_message}'", f"working properly: '{error_message}'",
RuntimeWarning, RuntimeWarning,
) )
except Exception as e: except Exception as e:
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}") logger.error(
f"Error connecting to {funding_source.__class__.__name__}: {e}"
)
if retry_counter == max_retries: if retry_counter >= max_retries:
set_void_wallet_class() set_void_wallet_class()
WALLET = get_wallet_class() funding_source = get_funding_source()
break break
retry_counter += 1 retry_counter += 1
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning( logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... " f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})" f"({retry_counter}/{max_retries})"
@@ -221,7 +216,7 @@ def set_void_wallet_class():
"Fallback to VoidWallet, because the backend for " "Fallback to VoidWallet, because the backend for "
f"{settings.lnbits_backend_wallet_class} isn't working properly" f"{settings.lnbits_backend_wallet_class} isn't working properly"
) )
set_wallet_class("VoidWallet") set_funding_source("VoidWallet")
async def check_installed_extensions(app: FastAPI): async def check_installed_extensions(app: FastAPI):
@@ -245,6 +240,7 @@ async def check_installed_extensions(app: FastAPI):
) )
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
await deactivate_extension(ext.id)
logger.warning( logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})" f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
) )
@@ -262,10 +258,10 @@ async def build_all_installed_extensions_list(
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL). MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
""" """
installed_extensions = await get_installed_extensions() installed_extensions = await get_installed_extensions()
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
installed_extensions_ids = [e.id for e in installed_extensions]
for ext_id in settings.lnbits_extensions_default_install: for ext_id in settings.lnbits_extensions_default_install:
if ext_id in installed_extensions_ids: if ext_id in settings.lnbits_all_extensions_ids:
continue continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id) ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -300,7 +296,7 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip")) zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
if f"./{str(ext.zip_path)}" not in zip_files: if f"./{ext.zip_path!s}" not in zip_files:
await ext.download_archive() await ext.download_archive()
ext.extract_archive() ext.extract_archive()
@@ -319,8 +315,6 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
# mount routes for the new version # mount routes for the new version
core_app_extra.register_new_ext_routes(extension) core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext.notify_upgrade()
def register_custom_extensions_path(): def register_custom_extensions_path():
@@ -383,84 +377,41 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
) )
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"]) app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"): ext_redirects = (
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths") getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [ if hasattr(ext_module, f"{ext.code}_redirect_paths")
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code else []
] )
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
logger.trace(f"adding route for extension {ext_module}") settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else "" prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def register_all_ext_routes(app: FastAPI): async def check_and_register_extensions(app: FastAPI):
for ext in get_valid_extensions(False): await check_installed_extensions(app)
for ext in Extension.get_valid_extensions(False):
try: try:
register_ext_routes(app, ext) register_ext_routes(app, ext)
except Exception as e: except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {str(e)}") logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def initialize_server_logger(): def register_async_tasks(app: FastAPI):
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
serverlog_queue = asyncio.Queue() # check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
async def update_websocket_serverlog():
while True:
msg = await serverlog_queue.get()
await websocketUpdater(super_user_hash, msg)
create_permanent_task(update_websocket_serverlog)
logger.add(
lambda msg: serverlog_queue.put_nowait(msg),
format=Formatter().format,
)
def log_server_info():
logger.info("Starting LNbits")
logger.info(f"Version: {settings.version}")
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
logger.info(f"Host: {settings.host}")
logger.info(f"Port: {settings.port}")
logger.info(f"Debug: {settings.debug}")
logger.info(f"Site title: {settings.lnbits_site_title}")
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
logger.info(f"Data folder: {settings.lnbits_data_folder}")
logger.info(f"Database: {get_db_vendor_name()}")
logger.info(f"Service fee: {settings.lnbits_service_fee}")
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (
"PostgreSQL"
if db_url and db_url.startswith("postgres://")
else (
"CockroachDB"
if db_url and db_url.startswith("cockroachdb://")
else "SQLite"
)
)
def register_async_tasks():
create_permanent_task(check_pending_payments) create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
# core invoice listener # core invoice listener
invoice_queue = asyncio.Queue(5) invoice_queue: asyncio.Queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core") register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
@@ -468,148 +419,7 @@ def register_async_tasks():
# create_permanent_task(watchdog_task) # create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task) create_permanent_task(killswitch_task)
# server logs for websocket
def register_exception_handlers(app: FastAPI): if settings.lnbits_admin_ui:
@app.exception_handler(Exception) server_log_task = initialize_server_websocket_logger()
async def exception_handler(request: Request, exc: Exception): create_permanent_task(server_log_task)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.error(f"Exception: {str(exc)}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {str(exc)}"}
)
return JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
logger.error(f"RequestValidationError: {str(exc)}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
request,
"error.html",
{"err": f"Error: {str(exc)}"},
)
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
if 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
return template_renderer().TemplateResponse(
request,
"error.html",
{
"request": request,
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
},
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
def configure_logger() -> None:
logger.remove()
log_level: str = "DEBUG" if settings.debug else "INFO"
formatter = Formatter()
logger.add(sys.stdout, level=log_level, format=formatter.format)
if settings.enable_log_to_file:
logger.add(
Path(settings.lnbits_data_folder, "logs", "lnbits.log"),
rotation=settings.log_rotation,
retention=settings.log_retention,
level="INFO",
format=formatter.format,
)
logger.add(
Path(settings.lnbits_data_folder, "logs", "debug.log"),
rotation=settings.log_rotation,
retention=settings.log_retention,
level="DEBUG",
format=formatter.format,
)
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").propagate = False
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").propagate = False
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False
class Formatter:
def __init__(self):
self.padding = 0
self.minimal_fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | "
"<level>{message}</level>\n"
)
if settings.debug:
self.fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | "
"<level>{level: <4}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"<level>{message}</level>\n"
)
else:
self.fmt = self.minimal_fmt
def format(self, record):
function = "{function}".format(**record)
if function == "emit": # uvicorn logs
return self.minimal_fmt
return self.fmt
class InterceptHandler(logging.Handler):
def emit(self, record):
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
logger.log(level, record.getMessage())
+34 -83
View File
@@ -12,7 +12,26 @@ from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from lnbits.core.models import Payment, User from lnbits.core import db as core_db
from lnbits.core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_payments,
remove_deleted_wallets,
update_payment_status,
)
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import ( from lnbits.core.views.extension_api import (
api_install_extension, api_install_extension,
@@ -21,30 +40,6 @@ from lnbits.core.views.extension_api import (
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets.base import Wallet from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_inactive_extensions,
get_installed_extension,
get_installed_extensions,
get_payments,
remove_deleted_wallets,
update_payment_status,
)
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
def coro(f): def coro(f):
@wraps(f) @wraps(f)
@@ -83,7 +78,7 @@ def get_super_user() -> Optional[str]:
"Superuser id not found. Please check that the file " "Superuser id not found. Please check that the file "
+ f"'{superuser_file.absolute()}' exists and has read permissions." + f"'{superuser_file.absolute()}' exists and has read permissions."
) )
with open(superuser_file, "r") as file: with open(superuser_file) as file:
return file.readline() return file.readline()
@@ -123,46 +118,6 @@ def database_migrate():
loop.run_until_complete(migrate_databases()) loop.run_until_complete(migrate_databases())
async def db_migrate():
asyncio.create_task(migrate_databases())
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions") @db.command("versions")
@coro @coro
async def db_versions(): async def db_versions():
@@ -205,7 +160,7 @@ async def database_delete_wallet(wallet: str):
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.") @click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro @coro
async def database_delete_wallet_payment(wallet: str, checking_id: str): async def database_delete_wallet_payment(wallet: str, checking_id: str):
"""Mark wallet as deleted""" """Delete wallet payment"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
await delete_wallet_payment( await delete_wallet_payment(
wallet_id=wallet, checking_id=checking_id, conn=conn wallet_id=wallet, checking_id=checking_id, conn=conn
@@ -215,10 +170,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending") @db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.") @click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro @coro
async def database_revert_payment(checking_id: str, pending: bool = True): async def database_revert_payment(checking_id: str):
"""Mark wallet as deleted""" """Mark payment as pending"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn) await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts") @db.command("cleanup-accounts")
@@ -312,12 +269,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)])) click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions()
settings.lnbits_deactivated_extensions += inactive_extensions
@extensions.command("list") @extensions.command("list")
@coro @coro
async def extensions_list(): async def extensions_list():
@@ -492,7 +443,7 @@ async def extensions_uninstall(
click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.") click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.")
return False, ex.detail return False, ex.detail
except Exception as ex: except Exception as ex:
click.echo(f"Failed to uninstall '{extension}': {str(ex)}.") click.echo(f"Failed to uninstall '{extension}': {ex!s}.")
return False, str(ex) return False, str(ex)
@@ -530,7 +481,7 @@ async def install_extension(
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.") click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
return False, ex.detail return False, ex.detail
except Exception as ex: except Exception as ex:
click.echo(f"Failed to install '{extension}': {str(ex)}.") click.echo(f"Failed to install '{extension}': {ex!s}.")
return False, str(ex) return False, str(ex)
@@ -582,7 +533,7 @@ async def update_extension(
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.") click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
return False, ex.detail return False, ex.detail
except Exception as ex: except Exception as ex:
click.echo(f"Failed to update '{extension}': {str(ex)}.") click.echo(f"Failed to update '{extension}': {ex!s}.")
return False, str(ex) return False, str(ex)
@@ -605,7 +556,7 @@ async def _select_release(
return latest_repo_releases[source_repo] return latest_repo_releases[source_repo]
if len(latest_repo_releases) == 1: if len(latest_repo_releases) == 1:
return latest_repo_releases[list(latest_repo_releases.keys())[0]] return latest_repo_releases[next(iter(latest_repo_releases.keys()))]
repos = list(latest_repo_releases.keys()) repos = list(latest_repo_releases.keys())
repos.sort() repos.sort()
@@ -660,7 +611,7 @@ async def _call_install_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_install_extension(data, User(id="mock_id")) await api_install_extension(data)
async def _call_uninstall_extension( async def _call_uninstall_extension(
@@ -674,7 +625,7 @@ async def _call_uninstall_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_uninstall_extension(extension, User(id="mock_id")) await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool: async def _can_run_operation(url) -> bool:
@@ -693,7 +644,7 @@ async def _can_run_operation(url) -> bool:
elif url: elif url:
click.echo( click.echo(
"The option '--url' has been provided," "The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'" f" but no server found running at '{url}'"
) )
return False return False
+6 -1
View File
@@ -7,11 +7,12 @@ from .views.auth_api import auth_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension from .views.generic import generic_router
from .views.node_api import node_router, public_node_router, super_node_router from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router from .views.payment_api import payment_router
from .views.public_api import public_router from .views.public_api import public_router
from .views.tinyurl_api import tinyurl_router from .views.tinyurl_api import tinyurl_router
from .views.user_api import users_router
from .views.wallet_api import wallet_router from .views.wallet_api import wallet_router
from .views.webpush_api import webpush_router from .views.webpush_api import webpush_router
from .views.websocket_api import websocket_router from .views.websocket_api import websocket_router
@@ -36,3 +37,7 @@ def init_core_routers(app: FastAPI):
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router)
__all__ = ["core_app", "core_app_extra", "db"]
+435 -415
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
import asyncio
import importlib
from loguru import logger
from lnbits.core.crud import (
add_installed_extension,
delete_installed_extension,
get_dbversions,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_info.id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
db_version = (await get_dbversions()).get(ext_info.id, 0)
await migrate_extension_database(extension, db_version)
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(ext_info.id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function.
"""
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
+56
View File
@@ -0,0 +1,56 @@
import hashlib
from typing import Any, Optional
from urllib import request
import httpx
from loguru import logger
from packaging import version
from lnbits.settings import settings
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json import json
@@ -6,16 +8,22 @@ import shutil
import sys import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple from typing import Any, NamedTuple, Optional
from urllib import request
import httpx import httpx
from loguru import logger from loguru import logger
from packaging import version
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.settings import settings from lnbits.settings import settings
from .helpers import (
download_url,
file_hash,
github_api_get,
icon_to_github_url,
version_parse,
)
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
id: str id: str
@@ -23,7 +31,7 @@ class ExplicitRelease(BaseModel):
version: str version: str
archive: str archive: str
hash: str hash: str
dependencies: List[str] = [] dependencies: list[str] = []
repo: Optional[str] repo: Optional[str]
icon: Optional[str] icon: Optional[str]
short_description: Optional[str] short_description: Optional[str]
@@ -32,6 +40,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str] warning: Optional[str]
info_notification: Optional[str] info_notification: Optional[str]
critical_notification: Optional[str] critical_notification: Optional[str]
details_link: Optional[str]
pay_link: Optional[str] pay_link: Optional[str]
def is_version_compatible(self): def is_version_compatible(self):
@@ -47,9 +56,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel): class Manifest(BaseModel):
featured: List[str] = [] featured: list[str] = []
extensions: List["ExplicitRelease"] = [] extensions: list[ExplicitRelease] = []
repos: List["GitHubRelease"] = [] repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel): class GitHubRepoRelease(BaseModel):
@@ -58,6 +67,9 @@ class GitHubRepoRelease(BaseModel):
zipball_url: str zipball_url: str
html_url: str html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel): class GitHubRepo(BaseModel):
stargazers_count: str stargazers_count: str
@@ -77,6 +89,17 @@ class ExtensionConfig(BaseModel):
return True return True
return version_parse(self.min_lnbits_version) <= version_parse(settings.version) return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None amount: Optional[int] = None
@@ -85,104 +108,37 @@ class ReleasePaymentInfo(BaseModel):
payment_request: Optional[str] = None payment_request: Optional[str] = None
def download_url(url, save_path): class PayToEnableInfo(BaseModel):
with request.urlopen(url, timeout=60) as dl_file: required: Optional[bool] = False
with open(save_path, "wb") as out_file: amount: Optional[int] = None
out_file.write(dl_file.read()) wallet: Optional[str] = None
def file_hash(filename): class UserExtensionInfo(BaseModel):
h = hashlib.sha256() paid_to_enable: Optional[bool] = False
b = bytearray(128 * 1024) payment_hash_to_enable: Optional[str] = None
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( class UserExtension(BaseModel):
org: str, repository: str extension: str
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]: active: bool
repo_url = f"https://api.github.com/repos/{org}/{repository}" extra: Optional[UserExtensionInfo] = None
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = ( @property
f"https://api.github.com/repos/{org}/{repository}/releases/latest" def is_paid(self) -> bool:
) if not self.extra:
error_msg = "Cannot fetch extension releases" return False
latest_release: Any = await github_api_get(lates_release_url, error_msg) return self.extra.paid_to_enable is True
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json" @classmethod
error_msg = "Cannot fetch config for extension" def from_row(cls, data: dict) -> UserExtension:
config = await github_api_get(config_url, error_msg) ext = UserExtension(**data)
ext.extra = (
return ( UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
github_repo, if "_extra" in data
GitHubRepoRelease.parse_obj(latest_release), else None
ExtensionConfig.parse_obj(config), )
) return ext
async def fetch_manifest(url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
async def fetch_github_release_config(
org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
async def fetch_release_payment_info(
url: str, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
if amount:
url = f"{url}?amount={amount}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
class Extension(NamedTuple): class Extension(NamedTuple):
@@ -192,7 +148,7 @@ class Extension(NamedTuple):
name: Optional[str] = None name: Optional[str] = None
short_description: Optional[str] = None short_description: Optional[str] = None
tile: Optional[str] = None tile: Optional[str] = None
contributors: Optional[List[str]] = None contributors: Optional[list[str]] = None
hidden: bool = False hidden: bool = False
migration_module: Optional[str] = None migration_module: Optional[str] = None
db_name: Optional[str] = None db_name: Optional[str] = None
@@ -214,7 +170,7 @@ class Extension(NamedTuple):
return self.upgrade_hash != "" return self.upgrade_hash != ""
@classmethod @classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension": def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension( return Extension(
code=ext_info.id, code=ext_info.id,
is_valid=True, is_valid=True,
@@ -223,21 +179,43 @@ class Extension(NamedTuple):
upgrade_hash=ext_info.hash if ext_info.module_installed else "", upgrade_hash=ext_info.hash if ext_info.module_installed else "",
) )
@classmethod
def get_valid_extensions(
cls, include_deactivated: Optional[bool] = True
) -> list[Extension]:
valid_extensions = [
extension for extension in cls._extensions() if extension.is_valid
]
# All subdirectories in the current directory, not recursive. if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
class ExtensionManager: return [
def __init__(self) -> None: e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
@classmethod
def get_valid_extension(
cls, ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
all_extensions = cls.get_valid_extensions(include_deactivated)
return next((e for e in all_extensions if e.code == ext_id), None)
@classmethod
def _extensions(cls) -> list[Extension]:
p = Path(settings.lnbits_extensions_path, "extensions") p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True) Path(p).mkdir(parents=True, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()] extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
@property # todo: remove this property somehow, it is too expensive
def extensions(self) -> List[Extension]: output: list[Extension] = []
output: List[Extension] = []
for extension_folder in self._extension_folders: for extension_folder in extension_folders:
extension_code = extension_folder.parts[-1] extension_code = extension_folder.parts[-1]
try: try:
with open(extension_folder / "config.json") as json_file: with open(extension_folder / "config.json") as json_file:
@@ -281,6 +259,7 @@ class ExtensionRelease(BaseModel):
warning: Optional[str] = None warning: Optional[str] = None
repo: Optional[str] = None repo: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
details_link: Optional[str] = None
pay_link: Optional[str] = None pay_link: Optional[str] = None
cost_sats: Optional[int] = None cost_sats: Optional[int] = None
@@ -299,13 +278,27 @@ class ExtensionRelease(BaseModel):
if not self.pay_link: if not self.pay_link:
return return
payment_info = await fetch_release_payment_info(self.pay_link) payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod @classmethod
def from_github_release( def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease" cls, source_repo: str, r: GitHubRepoRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=r.name, name=r.name,
description=r.name, description=r.name,
@@ -313,14 +306,15 @@ class ExtensionRelease(BaseModel):
archive=r.zipball_url, archive=r.zipball_url,
source_repo=source_repo, source_repo=source_repo,
is_github_release=True, is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}", repo=f"https://github.com/{source_repo}",
html_url=r.html_url, html_url=r.html_url,
) )
@classmethod @classmethod
def from_explicit_release( def from_explicit_release(
cls, source_repo: str, e: "ExplicitRelease" cls, source_repo: str, e: ExplicitRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=e.name, name=e.name,
version=e.version, version=e.version,
@@ -332,15 +326,16 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(), is_version_compatible=e.is_version_compatible(),
warning=e.warning, warning=e.warning,
html_url=e.html_url, html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link, pay_link=e.pay_link,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@classmethod @classmethod
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
return [ return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
@@ -349,19 +344,48 @@ class ExtensionRelease(BaseModel):
logger.warning(e) logger.warning(e)
return [] return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
id: str id: str
name: str name: str
active: Optional[bool] = False
short_description: Optional[str] = None short_description: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
dependencies: List[str] = [] dependencies: list[str] = []
is_admin_only: bool = False is_admin_only: bool = False
stars: int = 0 stars: int = 0
featured = False featured = False
latest_release: Optional[ExtensionRelease] = None latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = [] payments: list[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None archive: Optional[str] = None
@property @property
@@ -412,6 +436,12 @@ class InstallableExtension(BaseModel):
return self.installed_release.version return self.installed_release.version
return "" return ""
@property
def requires_payment(self) -> bool:
if not self.pay_to_enable:
return False
return self.pay_to_enable.required is True
async def download_archive(self): async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).") logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path ext_zip_file = self.zip_path
@@ -428,9 +458,9 @@ class InstallableExtension(BaseModel):
self._remember_payment_info() self._remember_payment_info()
except Exception as ex: except Exception as exc:
logger.warning(ex) logger.warning(exc)
raise AssertionError("Cannot fetch extension archive file") raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file) archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash: if self.installed_release.hash and self.installed_release.hash != archive_hash:
@@ -479,22 +509,6 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir)) shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.") logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def notify_upgrade(self) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
clean_upgraded_exts = list(
filter(
lambda old_ext: not old_ext.endswith(f"/{self.id}"),
settings.lnbits_upgraded_extensions,
)
)
settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
f"{self.hash}/{self.id}"
]
def clean_extension_files(self): def clean_extension_files(self):
# remove downloaded archive # remove downloaded archive
if self.zip_path.is_file(): if self.zip_path.is_file():
@@ -549,39 +563,44 @@ class InstallableExtension(BaseModel):
self.payments.append(payment_info) self.payments.append(payment_info)
@classmethod @classmethod
def from_row(cls, data: dict) -> "InstallableExtension": def from_row(cls, data: dict) -> InstallableExtension:
meta = json.loads(data["meta"]) meta = json.loads(data["meta"])
ext = InstallableExtension(**data) ext = InstallableExtension(**data)
if "installed_release" in meta: if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"]) 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"): if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]] ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext return ext
@classmethod @classmethod
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]: 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] return [InstallableExtension.from_row(row) for row in rows]
@classmethod @classmethod
async def from_github_release( async def from_github_release(
cls, github_release: GitHubRelease cls, github_release: GitHubRelease
) -> Optional["InstallableExtension"]: ) -> Optional[InstallableExtension]:
try: try:
repo, latest_release, config = await fetch_github_repo_info( repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository github_release.organisation, github_release.repository
) )
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension( return InstallableExtension(
id=github_release.id, id=github_release.id,
name=config.name, name=config.name,
short_description=config.short_description, short_description=config.short_description,
stars=int(repo.stargazers_count), stars=int(repo.stargazers_count),
icon=icon_to_github_url( icon=icon_to_github_url(
f"{github_release.organisation}/{github_release.repository}", source_repo,
config.tile, config.tile,
), ),
latest_release=ExtensionRelease.from_github_release( latest_release=ExtensionRelease.from_github_release(
repo.html_url, latest_release source_repo, latest_release
), ),
) )
except Exception as e: except Exception as e:
@@ -589,7 +608,7 @@ class InstallableExtension(BaseModel):
return None return None
@classmethod @classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension": def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
return InstallableExtension( return InstallableExtension(
id=e.id, id=e.id,
name=e.name, name=e.name,
@@ -602,13 +621,13 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, cls,
) -> List["InstallableExtension"]: ) -> list[InstallableExtension]:
extension_list: List[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
extension_id_list: List[str] = [] extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r) ext = await InstallableExtension.from_github_release(r)
@@ -639,17 +658,17 @@ class InstallableExtension(BaseModel):
extension_list += [ext] extension_list += [ext]
extension_id_list += [e.id] extension_id_list += [e.id]
except Exception as e: except Exception as e:
logger.warning(f"Manifest {url} failed with '{str(e)}'") logger.warning(f"Manifest {url} failed with '{e!s}'")
return extension_list return extension_list
@classmethod @classmethod
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]: async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: List[ExtensionRelease] = [] extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
if r.id != ext_id: if r.id != ext_id:
continue continue
@@ -666,15 +685,15 @@ class InstallableExtension(BaseModel):
extension_releases.append(explicit_release) extension_releases.append(explicit_release)
except Exception as e: except Exception as e:
logger.warning(f"Manifest {url} failed with '{str(e)}'") logger.warning(f"Manifest {url} failed with '{e!s}'")
return extension_releases return extension_releases
@classmethod @classmethod
async def get_extension_release( async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]: ) -> Optional[ExtensionRelease]:
all_releases: List[ExtensionRelease] = ( all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
) )
selected_release = [ selected_release = [
@@ -687,6 +706,37 @@ class InstallableExtension(BaseModel):
return selected_release[0] if len(selected_release) != 0 else None return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel): class CreateExtension(BaseModel):
ext_id: str ext_id: str
@@ -697,30 +747,7 @@ class CreateExtension(BaseModel):
payment_hash: Optional[str] = None payment_hash: Optional[str] = None
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]: class ExtensionDetailsRequest(BaseModel):
valid_extensions = [ ext_id: str
extension for extension in ExtensionManager().extensions if extension.is_valid source_repo: str
] version: str
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
+59 -71
View File
@@ -1,28 +1,33 @@
import importlib import importlib
import re import re
from typing import Any, Optional from typing import Any
from uuid import UUID from uuid import UUID
import httpx
from loguru import logger from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db from lnbits.core.db import db as core_db
from lnbits.db import Connection from lnbits.core.extensions.models import (
from lnbits.extension_manager import Extension Extension,
)
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(ext: Extension, current_version): async def migrate_extension_database(ext: Extension, current_version):
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db ext_db = importlib.import_module(ext.module_name).db
except ImportError as e: except ImportError as exc:
logger.error(e) logger.error(exc)
raise ImportError( raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file." f"Please make sure that the extension `{ext.code}` has a migrations file."
) ) from exc
async with ext_db.connect() as ext_conn: async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version) await run_migration(ext_conn, ext_migrations, ext.code, current_version)
@@ -48,72 +53,55 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
await getattr(old_module, stop_fn_name)()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
try: try:
int(user_id, 16) int(user_id, 16)
except Exception: except Exception as exc:
raise ValueError("Invalid hex string for User ID.") raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4) return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_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 Extension.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.")
+123 -51
View File
@@ -1,4 +1,3 @@
import datetime
from time import time from time import time
from loguru import logger from loguru import logger
@@ -102,7 +101,7 @@ async def m002_add_fields_to_apipayments(db):
import json import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall() rows = await db.fetchall("SELECT * FROM apipayments")
for row in rows: for row in rows:
if not row["memo"] or not row["memo"].startswith("#"): if not row["memo"] or not row["memo"].startswith("#"):
continue continue
@@ -113,15 +112,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :] new = row["memo"][len(prefix) :]
await db.execute( await db.execute(
""" """
UPDATE apipayments SET extra = ?, memo = ? UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = ? AND memo = ? WHERE checking_id = :checking_id AND memo = :memo2
""", """,
( {
json.dumps({"tag": ext}), "extra": json.dumps({"tag": ext}),
new, "memo1": new,
row["checking_id"], "checking_id": row["checking_id"],
row["memo"], "memo2": row["memo"],
), },
) )
break break
except OperationalError: except OperationalError:
@@ -212,19 +211,17 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments. Precomputes invoice expiry for existing pending incoming payments.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( f"""
f""" SELECT bolt11, checking_id
SELECT bolt11, checking_id FROM apipayments
FROM apipayments WHERE pending = true
WHERE pending = true AND amount > 0
AND amount > 0 AND bolt11 IS NOT NULL
AND bolt11 IS NOT NULL AND expiry IS NULL
AND expiry IS NULL AND time < {db.timestamp_now}
AND time < {db.timestamp_now} """
""" )
)
).fetchall()
if len(rows): if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices") logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, ( for i, (
@@ -236,22 +233,17 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None: if invoice.expiry is None:
continue continue
expiration_date = datetime.datetime.fromtimestamp( expiration_date = invoice.date + invoice.expiry
invoice.date + invoice.expiry
)
logger.info( logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice" f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}" f" {invoice.payment_hash} to {expiration_date}"
) )
await db.execute( await db.execute(
""" f"""
UPDATE apipayments SET expiry = ? UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = ? AND amount > 0 WHERE checking_id = :checking_id AND amount > 0
""", """,
( {"expiry": expiration_date, "checking_id": checking_id},
db.datetime_to_timestamp(expiration_date),
checking_id,
),
) )
except Exception: except Exception:
continue continue
@@ -347,17 +339,15 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets. Sets deleted column to wallets.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( """
""" SELECT *
SELECT * FROM wallets
FROM wallets WHERE user LIKE 'del:%'
WHERE user LIKE 'del:%' AND adminkey LIKE 'del:%'
AND adminkey LIKE 'del:%' AND inkey LIKE 'del:%'
AND inkey LIKE 'del:%' """
""" )
)
).fetchall()
for row in rows: for row in rows:
try: try:
@@ -366,10 +356,16 @@ async def m014_set_deleted_wallets(db):
inkey = row[4].split(":")[1] inkey = row[4].split(":")[1]
await db.execute( await db.execute(
""" """
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true UPDATE wallets SET
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: except Exception:
continue continue
@@ -455,19 +451,95 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
now = int(time()) now = int(time())
await db.execute( await db.execute(
f""" f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder} UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
await db.execute( await db.execute(
f""" f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder} UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
except OperationalError as exc: except OperationalError as exc:
logger.error(f"Migration 17 failed: {exc}") logger.error(f"Migration 17 failed: {exc}")
pass pass
async def m018_balances_view_exclude_deleted(db):
"""
Make deleted wallets not show up in the balances view.
"""
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 apipayments
LEFT JOIN wallets ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY wallet
"""
)
async def m019_balances_view_based_on_wallets(db):
"""
Make deleted wallets not show up in the balances view.
Important for querying whole lnbits balances.
"""
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.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY apipayments.wallet
"""
)
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
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
+125 -102
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import datetime import datetime
import hashlib import hashlib
import hmac import hmac
@@ -5,20 +7,24 @@ import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from sqlite3 import Row from typing import Callable, Optional
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
from fastapi import Query from fastapi import Query
from loguru import logger from pydantic import BaseModel, validator
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel from lnbits.db import FilterModel, FromRowModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class BaseWallet(BaseModel): class BaseWallet(BaseModel):
@@ -62,13 +68,13 @@ class Wallet(BaseWallet):
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256 linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
) )
async def get_payment(self, payment_hash: str) -> Optional["Payment"]: async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash) return await get_standalone_payment(payment_hash)
class WalletType(Enum): class KeyType(Enum):
admin = 0 admin = 0
invoice = 1 invoice = 1
invalid = 2 invalid = 2
@@ -80,7 +86,7 @@ class WalletType(Enum):
@dataclass @dataclass
class WalletTypeInfo: class WalletTypeInfo:
wallet_type: WalletType key_type: KeyType
wallet: Wallet wallet: Wallet
@@ -97,12 +103,43 @@ class UserConfig(BaseModel):
provider: Optional[str] = "lnbits" # auth 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): class User(BaseModel):
id: str id: str
email: Optional[str] = None email: Optional[str] = None
username: Optional[str] = None username: Optional[str] = None
extensions: List[str] = [] extensions: list[str] = []
wallets: List[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
has_password: bool = False has_password: bool = False
@@ -111,10 +148,10 @@ class User(BaseModel):
updated_at: Optional[int] = None updated_at: Optional[int] = None
@property @property
def wallet_ids(self) -> List[str]: def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets] return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]: def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id] w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None return w[0] if w else None
@@ -166,9 +203,33 @@ class LoginUsernamePassword(BaseModel):
password: str password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime.datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(FromRowModel): class Payment(FromRowModel):
checking_id: str status: str
# TODO should be removed in the future, backward compatibility
pending: bool pending: bool
checking_id: str
amount: int amount: int
fee: int fee: int
memo: Optional[str] memo: Optional[str]
@@ -177,20 +238,30 @@ class Payment(FromRowModel):
preimage: str preimage: str
payment_hash: str payment_hash: str
expiry: Optional[float] expiry: Optional[float]
extra: Dict = {} extra: Optional[dict]
wallet_id: str wallet_id: str
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int]
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls( return cls(
checking_id=row["checking_id"], checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64, payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "", bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64, preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"), extra=json.loads(row["extra"] or "{}"),
pending=row["pending"], status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"], amount=row["amount"],
fee=row["fee"], fee=row["fee"],
memo=row["memo"], memo=row["memo"],
@@ -228,83 +299,23 @@ class Payment(FromRowModel):
return self.expiry < time.time() if self.expiry else False return self.expiry < time.time() if self.expiry else False
@property @property
def is_uncheckable(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_")
async def update_status( async def check_status(self) -> PaymentStatus:
self, if self.is_internal:
status: PaymentStatus, if self.success:
conn: Optional[Connection] = None, return PaymentSuccessStatus()
) -> None: if self.failed:
from .crud import update_payment_details return PaymentFailedStatus()
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
WALLET = get_wallet_class()
if self.is_out: if self.is_out:
status = await WALLET.get_payment_status(self.checking_id) status = await funding_source.get_payment_status(self.checking_id)
else: else:
status = await WALLET.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"] __search_fields__ = ["memo", "amount"]
@@ -318,7 +329,7 @@ class PaymentFilters(FilterModel):
preimage: str preimage: str
payment_hash: str payment_hash: str
expiry: Optional[datetime.datetime] expiry: Optional[datetime.datetime]
extra: Dict = {} extra: dict = {}
wallet_id: str wallet_id: str
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int]
@@ -331,16 +342,6 @@ class PaymentHistoryPoint(BaseModel):
balance: int balance: int
class BalanceCheck(BaseModel):
wallet: str
service: str
url: str
@classmethod
def from_row(cls, row: Row):
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
def _do_nothing(*_): def _do_nothing(*_):
pass pass
@@ -358,7 +359,7 @@ class TinyURL(BaseModel):
time: float time: float
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls(**dict(row)) return cls(**dict(row))
@@ -374,6 +375,7 @@ class Callback(BaseModel):
class DecodePayment(BaseModel): class DecodePayment(BaseModel):
data: str data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel): class CreateLnurl(BaseModel):
@@ -394,11 +396,18 @@ class CreateInvoice(BaseModel):
description_hash: Optional[str] = None description_hash: Optional[str] = None
unhashed_description: Optional[str] = None unhashed_description: Optional[str] = None
expiry: Optional[int] = None expiry: Optional[int] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None extra: Optional[dict] = None
webhook: Optional[str] = None webhook: Optional[str] = None
bolt11: 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
class CreateTopup(BaseModel): class CreateTopup(BaseModel):
@@ -424,3 +433,17 @@ class WebPushSubscription(BaseModel):
data: str data: str
host: str host: str
timestamp: 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
+262 -134
View File
@@ -1,24 +1,30 @@
import asyncio import asyncio
import datetime
import json import json
import time import time
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx import httpx
from bolt11 import Bolt11 from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket from fastapi import Depends, WebSocket
from loguru import logger from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid from py_vapid import Vapid
from py_vapid.utils import b64urlencode from py_vapid.utils import b64urlencode
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.decorators import WalletTypeInfo, require_admin_key from lnbits.decorators import (
WalletTypeInfo,
check_user_extension_access,
require_admin_key,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl from lnbits.lnurl import decode as decode_lnurl
@@ -30,7 +36,7 @@ from lnbits.settings import (
settings, settings,
) )
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
@@ -45,8 +51,9 @@ from .crud import (
create_admin_settings, create_admin_settings,
create_payment, create_payment,
create_wallet, create_wallet,
delete_wallet_payment,
get_account, get_account,
get_account_by_email,
get_account_by_username,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
get_super_settings, get_super_settings,
@@ -57,26 +64,27 @@ from .crud import (
update_payment_details, update_payment_details,
update_payment_status, update_payment_status,
update_super_user, update_super_user,
update_user_extension,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import Payment, UserConfig, Wallet from .models import (
BalanceDelta,
CreatePayment,
class PaymentFailure(Exception): Payment,
pass PaymentState,
User,
UserConfig,
class InvoiceFailure(Exception): Wallet,
pass )
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
amount: float, amount: float,
wallet_id: str, wallet_id: str,
currency: Optional[str] = None, currency: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]: ) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id" assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
@@ -117,22 +125,22 @@ async def create_invoice(
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None, expiry: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[str, str]: ) -> tuple[str, str]:
if not amount > 0: if not amount > 0:
raise InvoiceFailure("Amountless invoices not supported.") raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn) user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet: if not user_wallet:
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.") raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only # use the fake wallet if the invoice is for internal use only
wallet = FAKE_WALLET if internal else get_wallet_class() funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts( amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn amount, wallet_id, currency=currency, extra=extra, conn=conn
@@ -141,12 +149,18 @@ async def create_invoice(
if settings.is_wallet_max_balance_exceeded( if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat user_wallet.balance_msat / 1000 + amount_sat
): ):
raise InvoiceFailure( raise InvoiceError(
f"Wallet balance cannot exceed " f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats." f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
) )
ok, checking_id, payment_request, error_message = await wallet.create_invoice( (
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat, amount=amount_sat,
memo=invoice_memo, memo=invoice_memo,
description_hash=description_hash, description_hash=description_hash,
@@ -154,21 +168,26 @@ async def create_invoice(
expiry=expiry or settings.lightning_invoice_expiry, expiry=expiry or settings.lightning_invoice_expiry,
) )
if not ok or not payment_request or not checking_id: if not ok or not payment_request or not checking_id:
raise InvoiceFailure(error_message or "unexpected backend error.") raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat create_payment_model = CreatePayment(
await create_payment(
wallet_id=wallet_id, wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=amount_msat, amount=amount_sat * 1000,
expiry=get_bolt11_expiry(invoice), expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
)
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn, conn=conn,
) )
@@ -180,7 +199,7 @@ async def pay_invoice(
wallet_id: str, wallet_id: str,
payment_request: str, payment_request: str,
max_sat: Optional[int] = None, max_sat: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
@@ -196,13 +215,13 @@ async def pay_invoice(
""" """
try: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
except Exception: except Exception as exc:
raise InvoiceFailure("Bolt11 decoding failed.") raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0: if not invoice.amount_msat or not invoice.amount_msat > 0:
raise InvoiceFailure("Amountless invoices not supported.") raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise InvoiceFailure("Amount in invoice is too high.") raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat) await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
@@ -214,22 +233,12 @@ async def pay_invoice(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
) )
# put all parameters that don't change here create_payment_model = CreatePayment(
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat, amount=-invoice.amount_msat,
expiry=get_bolt11_expiry(invoice), expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
) )
@@ -237,15 +246,12 @@ async def pay_invoice(
# we check if an internal invoice exists that has already been paid # we check if an internal invoice exists that has already been paid
# (not pending anymore) # (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn): if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentFailure("Internal invoice already paid.") raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for # check_internal() returns the checking_id of the invoice we're waiting for
# (pending only) # (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id: if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment # perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice # the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
@@ -256,50 +262,40 @@ async def pay_invoice(
internal_invoice.amount != invoice.amount_msat internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower() or internal_invoice.bolt11 != payment_request.lower()
): ):
raise PaymentFailure("Invalid invoice.") raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet # create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment( new_payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat), data=create_payment_model,
pending=False, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
**payment_kwargs,
) )
else: else:
fee_reserve_total_msat = fee_reserve_total( new_payment = await _create_external_payment(
invoice.amount_msat, internal=False temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
) )
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as e:
logger.error(f"could not create temporary payment: {e}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentFailure("Could not make payment.")
# do the balance check # do the balance check
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched" assert wallet, "Wallet for balancecheck could not be fetched"
if wallet.balance_msat < 0: fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
logger.debug("balance is too low, deleting temporary payment") _check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if (
not internal_checking_id if extra and "tag" in extra:
and wallet.balance_msat > -fee_reserve_total_msat # check if the payment is made for an extension that the user disabled
): status = await check_user_extension_access(wallet.user, extra["tag"])
raise PaymentFailure( if not status.success:
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}" raise PaymentError(status.message)
" sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
if internal_checking_id: if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True) service_fee_msat = service_fee(invoice.amount_msat, internal=True)
@@ -309,7 +305,9 @@ async def pay_invoice(
# the payer has enough to deduct from # the payer has enough to deduct from
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_status( await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
) )
await send_payment_notification(wallet, new_payment) await send_payment_notification(wallet, new_payment)
@@ -323,8 +321,8 @@ async def pay_invoice(
service_fee_msat = service_fee(invoice.amount_msat, internal=False) service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}") logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice # actually pay the external invoice
WALLET = get_wallet_class() funding_source = get_funding_source()
payment: PaymentResponse = await WALLET.pay_invoice( payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat payment_request, fee_reserve_msat
) )
@@ -334,14 +332,18 @@ async def pay_invoice(
f" {payment.checking_id})" f" {payment.checking_id})"
) )
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False: if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)! # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}") logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_details( await update_payment_details(
checking_id=temp_id, checking_id=temp_id,
pending=payment.ok is not True, status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-( fee=-(
abs(payment.fee_msat if payment.fee_msat else 0) abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat) + abs(service_fee_msat)
@@ -356,16 +358,20 @@ async def pay_invoice(
) )
if wallet and updated: if wallet and updated:
await send_payment_notification(wallet, updated) await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}") logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False: elif payment.checking_id is None and payment.ok is False:
# payment failed # payment failed
logger.warning("backend sent payment failure") logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn: async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}") await update_payment_status(
await delete_wallet_payment(temp_id, wallet_id, conn=conn) checking_id=temp_id,
raise PaymentFailure( status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}" f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message." or "Payment failed, but backend didn't give us an error message.",
status="failed",
) )
else: else:
logger.warning( logger.warning(
@@ -375,19 +381,88 @@ async def pay_invoice(
# credit service fee wallet # credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat: if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
pending=False, amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
) )
return invoice.payment_hash return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection],
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
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): async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id) await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat) await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
@@ -408,8 +483,9 @@ async def check_time_limit_between_transactions(conn, wallet_id):
if len(payments) == 0: if len(payments) == 0:
return return
raise ValueError( raise PaymentError(
f"The time limit of {limit} seconds between payments has been reached." status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
) )
@@ -444,7 +520,7 @@ async def redeem_lnurl_withdraw(
wallet_id: str, wallet_id: str,
lnurl_request: str, lnurl_request: str,
memo: Optional[str] = None, memo: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
wait_seconds: int = 0, wait_seconds: int = 0,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
@@ -499,7 +575,6 @@ async def redeem_lnurl_withdraw(
async def perform_lnurlauth( async def perform_lnurlauth(
callback: str, callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
conn: Optional[Connection] = None,
) -> Optional[LnurlErrorResponse]: ) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback) cb = urlparse(callback)
@@ -583,16 +658,15 @@ async def check_transaction_status(
) )
if not payment: if not payment:
return PaymentPendingStatus() return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status() return await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to # WARN: this same value must be used for balance check and passed to
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ # funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int, internal: bool = False) -> int: def fee_reserve(amount_msat: int, internal: bool = False) -> int:
if internal: if internal:
return 0 return 0
@@ -621,8 +695,8 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment): async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocketUpdater( await websocket_updater(
wallet.id, wallet.inkey,
json.dumps( json.dumps(
{ {
"wallet_balance": wallet.balance, "wallet_balance": wallet.balance,
@@ -631,6 +705,10 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
), ),
) )
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
)
async def update_wallet_balance(wallet_id: str, amount: int): async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice( payment_hash, _ = await create_invoice(
@@ -642,7 +720,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn: async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn) checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved" assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn) await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -723,7 +803,7 @@ def update_cached_settings(sets_dict: dict):
except Exception: except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}") logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict: if "super_user" in sets_dict:
setattr(settings, "super_user", sets_dict["super_user"]) settings.super_user = sets_dict["super_user"]
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings: async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
@@ -742,9 +822,44 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
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: class WebsocketConnectionManager:
def __init__(self) -> None: def __init__(self) -> None:
self.active_connections: List[WebSocket] = [] self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str): async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}") logger.debug(f"Websocket connected to {item_id}")
@@ -760,33 +875,46 @@ class WebsocketConnectionManager:
await connection.send_text(message) await connection.send_text(message)
websocketManager = WebsocketConnectionManager() websocket_manager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data): async def websocket_updater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id) return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None: async def switch_to_voidwallet() -> None:
WALLET = get_wallet_class() funding_source = get_funding_source()
if WALLET.__class__.__name__ == "VoidWallet": if funding_source.__class__.__name__ == "VoidWallet":
return return
set_wallet_class("VoidWallet") set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet" settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> Tuple[int, int, int]: async def get_balance_delta() -> BalanceDelta:
WALLET = get_wallet_class() funding_source = get_funding_source()
total_balance = await get_total_balance() status = await funding_source.status()
error_message, node_balance = await WALLET.status() lnbits_balance = await get_total_balance()
if error_message: return BalanceDelta(
raise Exception(error_message) lnbits_balance_msats=lnbits_balance,
return node_balance - total_balance, node_balance, total_balance node_balance_msats=status.balance_msat,
)
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime: async def update_pending_payments(wallet_id: str):
if invoice.expiry: pending_payments = await get_payments(
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry) wallet_id=wallet_id,
else: pending=True,
# assume maximum bolt11 expiry of 31 days to be on the safe side exclude_uncheckable=True,
return datetime.datetime.now() + datetime.timedelta(days=31) )
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
+17 -34
View File
@@ -5,7 +5,6 @@ import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import ( from lnbits.core.crud import (
get_balance_notify,
get_wallet, get_wallet,
get_webpush_subscriptions_for_user, get_webpush_subscriptions_for_user,
mark_webhook_sent, mark_webhook_sent,
@@ -16,7 +15,7 @@ from lnbits.core.services import (
send_payment_notification, send_payment_notification,
switch_to_voidwallet, switch_to_voidwallet,
) )
from lnbits.settings import get_wallet_class, settings from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification from lnbits.tasks import send_push_notification
api_invoice_listeners: Dict[str, asyncio.Queue] = {} api_invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -27,9 +26,12 @@ async def killswitch_task():
killswitch will check lnbits-status repository for a signal from killswitch will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered. LNbits and will switch to VoidWallet if the killswitch is triggered.
""" """
while True: while settings.lnbits_running:
WALLET = get_wallet_class() funding_source = get_funding_source()
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet": if (
settings.lnbits_killswitch
and funding_source.__class__.__name__ != "VoidWallet"
):
with httpx.Client() as client: with httpx.Client() as client:
try: try:
r = client.get(settings.lnbits_status_manifest, timeout=4) r = client.get(settings.lnbits_status_manifest, timeout=4)
@@ -54,11 +56,15 @@ async def watchdog_task():
Registers a watchdog which will check lnbits balance and nodebalance Registers a watchdog which will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached. and will switch to VoidWallet if the watchdog delta is reached.
""" """
while True: while settings.lnbits_running:
WALLET = get_wallet_class() funding_source = get_funding_source()
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet": if (
settings.lnbits_watchdog
and funding_source.__class__.__name__ != "VoidWallet"
):
try: try:
delta, *_ = await get_balance_delta() balance = await get_balance_delta()
delta = balance.delta_msats
logger.debug(f"Running watchdog task. current delta: {delta}") logger.debug(f"Running watchdog task. current delta: {delta}")
if delta + settings.lnbits_watchdog_delta <= 0: if delta + settings.lnbits_watchdog_delta <= 0:
logger.error(f"Switching to VoidWallet. current delta: {delta}") logger.error(f"Switching to VoidWallet. current delta: {delta}")
@@ -70,10 +76,9 @@ async def watchdog_task():
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
""" """
This task dispatches events to all api_invoice_listeners, This worker dispatches events to all extensions and dispatches webhooks.
webhooks, push notifications and balance notifications.
""" """
while True: while settings.lnbits_running:
payment = await invoice_paid_queue.get() payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event") logger.trace("received invoice paid event")
# dispatch api_invoice_listeners # dispatch api_invoice_listeners
@@ -85,28 +90,6 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
# dispatch webhook # dispatch webhook
if payment.webhook and not payment.webhook_status: if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment) await dispatch_webhook(payment)
# dispatch balance_notify
url = await get_balance_notify(payment.wallet_id)
if url:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.post(url, timeout=4)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
await mark_webhook_sent(payment.payment_hash, status_code)
logger.warning(
f"balance_notify returned a bad status_code: {status_code} "
f"while requesting {exc.request.url!r}."
)
logger.warning(exc)
except httpx.RequestError as exc:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send balance_notify to {url}")
logger.warning(exc)
# dispatch push notification # dispatch push notification
await send_payment_push_notification(payment) await send_payment_push_notification(payment)
@@ -173,7 +173,7 @@
@remove="removeBlockedIPs(blocked_ip)" @remove="removeBlockedIPs(blocked_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
v-text="blocked_ip" :label="blocked_ip"
></q-chip> ></q-chip>
</div> </div>
<br /> <br />
@@ -202,7 +202,7 @@
@remove="removeAllowedIPs(allowed_ip)" @remove="removeAllowedIPs(allowed_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
v-text="allowed_ip" :label="allowed_ip"
></q-chip> ></q-chip>
</div> </div>
<br /> <br />
+95 -87
View File
@@ -1,24 +1,19 @@
<q-tab-panel name="server"> <q-tab-panel name="server">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6> <h6 class="q-my-none">Server Management</h6>
<br />
<div> <div>
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-6">
<p>Server Info</p> <p>Base URL</p>
<ul> <q-input
<li filled
v-if="settings.lnbits_data_folder" v-model.number="formData.lnbits_baseurl"
v-text="'SQlite: ' + settings.lnbits_data_folder" label="Static/Base url for the server"
></li> ></q-input>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<br /> <br />
</div> </div>
</div> </div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p>Allowed currencies</p> <p>Allowed currencies</p>
@@ -45,56 +40,7 @@
<br /> <br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<br /> <br />
<h6 class="q-my-none">Service Fee</h6> <h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -154,32 +100,94 @@
<br /> <br />
</div> </div>
</div> </div>
<q-separator></q-separator>
<h6 class="q-my-none">Extensions</h6> <h6 class="q-my-none">Extensions</h6>
<div> <div class="row q-col-gutter-md">
<p>Extension Sources</p> <div class="col-12">
<q-input <p>Extension Sources</p>
filled <q-input
v-model="formAddExtensionsManifest" filled
@keydown.enter="addExtensionsManifest" v-model="formAddExtensionsManifest"
type="text" @keydown.enter="addExtensionsManifest"
label="Source URL (only use the official LNbits extension source, and sources you can trust)" type="text"
hint="Repositories from where the extensions can be downloaded" label="Source URL (only use the official LNbits extension source, and sources you can trust)"
> hint="Repositories from where the extensions can be downloaded"
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn> >
</q-input> <q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
<div> </q-input>
<q-chip <div>
v-for="manifestUrl in formData.lnbits_extensions_manifests" <q-chip
:key="manifestUrl" v-for="manifestUrl in formData.lnbits_extensions_manifests"
removable :key="manifestUrl"
@remove="removeExtensionsManifest(manifestUrl)" removable
color="primary" @remove="removeExtensionsManifest(manifestUrl)"
text-color="white" color="primary"
><span v-text="manifestUrl"></span text-color="white"
></q-chip> ><span v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div> </div>
<br />
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
+35 -5
View File
@@ -4,7 +4,7 @@
<br /> <br />
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p>Site Title</p> <p>Site Title</p>
<q-input <q-input
filled filled
@@ -14,7 +14,7 @@
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p>Site Tagline</p> <p>Site Tagline</p>
<q-input <q-input
filled filled
@@ -24,7 +24,15 @@
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-2 q-mt-xl">
<q-toggle
tip="Remove homepage elements like 'runs on' etc"
v-model="formData.lnbits_show_home_page_elements"
:label="formData.lnbits_show_home_page_elements ? 'Enable elements on homepage' : 'Disable elements on homepage'"
></q-toggle>
</div>
</div> </div>
<div> <div>
<p>Site Description</p> <p>Site Description</p>
<q-input <q-input
@@ -44,7 +52,6 @@
v-model="formData.lnbits_default_wallet_name" v-model="formData.lnbits_default_wallet_name"
label="LNbits wallet" label="LNbits wallet"
></q-input> ></q-input>
<br />
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p>Denomination</p> <p>Denomination</p>
@@ -55,7 +62,6 @@
label="sats" label="sats"
hint="The name for the FakeWallet token" hint="The name for the FakeWallet token"
></q-input> ></q-input>
<br />
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p>QR code logo</p> <p>QR code logo</p>
@@ -66,9 +72,33 @@
label="https://example.com/image.svg" label="https://example.com/image.svg"
hint="URL to logo image in QR code" hint="URL to logo image in QR code"
></q-input> ></q-input>
<br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>Custom Badge</p>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<q-input
filled
type="text"
tip="Custom Badge"
v-model="formData.lnbits_custom_badge"
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-select
filled
v-model="formData.lnbits_custom_badge_color"
:options="colors"
label="Custom badge color"
></q-select>
</div>
</div>
</div>
</div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p>Themes</p> <p>Themes</p>
+2 -2
View File
@@ -23,8 +23,8 @@
@remove="removeAdminUser(user)" @remove="removeAdminUser(user)"
color="primary" color="primary"
text-color="white" text-color="white"
:label="user"
> >
<span v-text="user"></span>
</q-chip> </q-chip>
</div> </div>
<br /> <br />
@@ -49,8 +49,8 @@
@remove="removeAllowedUser(user)" @remove="removeAllowedUser(user)"
color="primary" color="primary"
text-color="white" text-color="white"
:label="user"
> >
<span v-text="user" />
</q-chip> </q-chip>
</div> </div>
<br /> <br />
-59
View File
@@ -40,17 +40,6 @@
/> />
</q-btn> </q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('topup')"
color="primary"
@click="topUpDialog.show = true"
>
<q-tooltip>
<span v-text="$t('add_funds_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn> <q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
<q-btn <q-btn
@@ -119,54 +108,6 @@
</div> </div>
</div> </div>
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<p v-text="$t('topup_wallet')"></p>
<div class="row">
<div class="col-12">
<q-input
dense
type="text"
filled
v-model="wallet.id"
label="Wallet ID"
:hint="$t('topup_hint')"
></q-input>
<br />
</div>
<div class="col-12">
<q-input
dense
type="number"
filled
v-model="wallet.amount"
:label="$t('amount')"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('topup')"
color="primary"
@click="topupWallet"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script> <script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
{% endblock %} {% endblock %}
+74 -9
View File
@@ -5,10 +5,77 @@
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card-section> <q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br /> <q-list>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br /> <q-item dense class="q-pa-none">
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br /> <q-item-section>
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em> <q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText('{{ wallet.id }}')"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.adminkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.inkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section> </q-card-section>
<q-expansion-item <q-expansion-item
group="api" group="api"
@@ -109,18 +176,16 @@
><span class="text-light-green">POST</span> ><span class="text-light-green">POST</span>
/api/v1/payments/decode</code /api/v1/payments/decode</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code> <code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json) Returns 200 (application/json)
</h5> </h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d >curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key: '{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code application/json"</code
> >
</q-card-section> </q-card-section>
</q-card> </q-card>
+21
View File
@@ -373,6 +373,27 @@
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col-4"> <div class="col-4">
<span v-text="$t('toggle_darkmode')"></span> <span v-text="$t('toggle_darkmode')"></span>
File diff suppressed because it is too large Load Diff
+87 -15
View File
@@ -8,12 +8,19 @@
></div> ></div>
<div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md"> <div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
<div class="gt-sm"> <div class="gt-sm">
<h3 class="q-my-none" v-if="'{{SITE_TITLE}}' == 'LNbits'"> <h3
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}} {{SITE_TITLE}}
</h3> </h3>
<h5 class="q-my-md" v-if="'{{SITE_TITLE}}' == 'LNbits'"> <h5 class="q-my-md" v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'">
{{SITE_TAGLINE}} {{SITE_TAGLINE}}
</h5> </h5>
<div
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div>
</div> </div>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in {% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
LNBITS_AUTH_METHODS)%} LNBITS_AUTH_METHODS)%}
@@ -26,7 +33,7 @@
color="primary" color="primary"
@click="processing" @click="processing"
type="a" type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}" href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')" v-text="$t('press_to_claim')"
class="full-width" class="full-width"
></q-btn> ></q-btn>
@@ -306,7 +313,7 @@
</div> </div>
<div <div
v-if="'{{SITE_TITLE}}' != 'LNbits'" v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'False'"
class="col-12 col-md-5 col-lg-5 q-pt-xl" class="col-12 col-md-5 col-lg-5 q-pt-xl"
> >
<h3 class="q-my-none">{{SITE_TITLE}}</h3> <h3 class="q-my-none">{{SITE_TITLE}}</h3>
@@ -331,7 +338,7 @@
outline outline
color="grey" color="grey"
type="a" type="a"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK" href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
:label="$t('donate')" :label="$t('donate')"
@@ -477,6 +484,32 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a <a
@@ -499,23 +532,62 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div class="col">
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %} </div>
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm"> {% if AD_SPACE %}
<q-btn flat color="secondary" class="full-width q-mb-md" <div class="row justify-center">
>{{ AD_SPACE_TITLE }}</q-btn <q-btn flat color="secondary" class="full-width q-mb-md"
> >{{ AD_SPACE_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md"> {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" /> <div class="flex flex-center column">
<img v-else src="{{ AD[2] }}" style="max-width: 90%" /> <a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
/>
<img v-else src="{{ AD[2] }}" style="max-width: 420px" />
</a> </a>
</div> </div>
{% endfor %} {% endif %}
{% endfor %}
</div> </div>
<div v-if="'{{SITE_TITLE}}' == 'LNbits'" class="row gt-sm q-mt-xl"> {% endif %}
<div
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
class="row gt-sm q-mt-xl"
>
<div class="col-1"></div> <div class="col-1"></div>
<div class="col-10 q-pl-xl"> <div class="col-10 q-pl-xl">
<span v-text="$t('lnbits_description')"></span> <span v-text="$t('lnbits_description')"></span>
+27 -253
View File
@@ -102,226 +102,11 @@
</div> </div>
</div> </div>
</q-card> </q-card>
<payment-list
<q-card :update="updatePayments"
:style="$q.screen.lt.md ? { :wallet="this.g.wallet"
background: $q.screen.lt.md ? 'none !important': '' :mobile-simple="mobileSimple"
, boxShadow: $q.screen.lt.md ? 'none !important': '' />
, marginTop: $q.screen.lt.md ? '0px !important': ''
} : ''"
>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn
flat
color="grey"
@click="exportCSV"
:label="$t('export_csv')"
></q-btn>
<q-btn
dense
flat
round
icon="show_chart"
color="grey"
@click="showChart"
>
<q-tooltip>
<span v-text="$t('chart_tooltip')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<q-input
:style="$q.screen.lt.md ? {
display: mobileSimple ? 'none !important': ''
} : ''"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:data="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip
><span v-text="$t('pending')"></span
></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge
v-if="props.row.tag"
color="yellow"
text-color="black"
>
<a
v-text="'#'+props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip
><span v-text="props.row.date"></span
></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
:props="props"
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
>
</q-td>
<q-td auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
></span>
</i>
</q-td>
</q-tr>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</template>
</q-table>
</q-card-section>
</q-card>
</div> </div>
{% if HIDE_API %} {% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md"> <div class="col-12 col-md-4 q-gutter-y-md">
@@ -369,11 +154,19 @@
<q-card> <q-card>
<q-card-section class="text-center"> <q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p> <p v-text="$t('export_to_phone_desc')"></p>
<qrcode <qrcode-vue
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'" :value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}" :options="{ width: 256 }"
></qrcode> ></qrcode-vue>
</q-card-section> </q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
></q-btn>
</q-card-actions>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator></q-separator> <q-separator></q-separator>
@@ -459,19 +252,21 @@
</q-list> </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = {% endif %} {% if AD_SPACE %}
ADS.split(";") %}
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm"> <h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ AD_SPACE_TITLE }} {{ AD_SPACE_TITLE }}
</h6> </h6>
</q-card-section> </q-card-section>
{% for ADS in AD_SPACE %} {% set AD = ADS.split(";") %}
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<a <a
style="display: inline-block" style="display: inline-block"
href="{{ AD[0] }}" href="{{ AD[0] }}"
class="q-ma-md" class="q-ml-md q-mb-xs q-mr-md"
style="max-width: 80%"
> >
<img <img
style="max-width: 100%; height: auto" style="max-width: 100%; height: auto"
@@ -483,8 +278,10 @@
v-else v-else
src="{{ AD[2] }}" src="{{ AD[2] }}"
/> />
</a> </q-card-section></q-card </a> </q-card-section
>{% endfor %} {% endif %} >{% endfor %}
</q-card>
{% endif %}
</div> </div>
</div> </div>
@@ -522,13 +319,11 @@
<q-input <q-input
ref="setAmount" ref="setAmount"
filled filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense dense
v-model.number="receive.data.amount" v-model.number="receive.data.amount"
:label="$t('amount') + ' (' + receive.unit + ') *'" :label="$t('amount') + ' (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]" :min="receive.minMax[0]"
:max="receive.minMax[1]" :max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
@@ -873,27 +668,6 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select
label="Group"
filled
dense
v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-tabs <q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top" class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0" active-class="px-0"
@@ -51,11 +51,11 @@
> >
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11"> <a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()" :value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
<q-btn <q-btn
@@ -138,11 +138,11 @@
> >
<a :href="'lightning:' + props.row.bolt11"> <a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()" :value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
</div> </div>
@@ -0,0 +1,23 @@
<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>
@@ -0,0 +1,23 @@
<q-dialog v-model="createWalletDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<p>Create Wallet</p>
<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>
</q-form>
</div>
</div>
</q-card>
</q-dialog>
@@ -0,0 +1,49 @@
<q-dialog v-model="topupDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<p v-text="$t('topup_wallet')"></p>
<div class="row">
<div class="col-12">
<q-input
dense
type="text"
filled
v-model="wallet.id"
label="Wallet ID"
:hint="$t('topup_hint')"
></q-input>
<br />
</div>
<div class="col-12">
<q-input
dense
type="number"
filled
v-model="wallet.amount"
:label="$t('amount')"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('topup')"
color="primary"
@click="topupWallet"
v-close-popup
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
@@ -0,0 +1,106 @@
<q-dialog v-model="walletDialog.show">
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
<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">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
auto-width
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
round
icon="menu"
size="sm"
color="secondary"
@click="showPayments(props.row.id)"
>
<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"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="secondary"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
@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 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>
+116
View File
@@ -0,0 +1,116 @@
{% 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>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 100%; max-width: 2000px">
<canvas ref="chart1"></canvas>
</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>
<q-table
row-key="id"
:rows="users"
:columns="usersTable.columns"
:pagination.sync="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@request="fetchUsers"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
round
icon="list"
size="sm"
color="secondary"
@click="fetchWallets(props.row.id)"
>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
<q-btn
round
icon="content_copy"
size="sm"
color="primary"
@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' : 'grey'"
@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>
</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-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/users.js') }}"></script>
{% endblock %}
+2 -40
View File
@@ -8,14 +8,11 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_wallet from lnbits.core.models import User
from lnbits.core.models import CreateTopup, User
from lnbits.core.services import ( from lnbits.core.services import (
get_balance_delta, get_balance_delta,
update_cached_settings, update_cached_settings,
update_wallet_balance,
) )
from lnbits.core.tasks import api_invoice_listeners from lnbits.core.tasks import api_invoice_listeners
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
@@ -36,18 +33,7 @@ admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
) )
async def api_auditor(): async def api_auditor():
try: return await get_balance_delta()
delta, node_balance, total_balance = await get_balance_delta()
return {
"delta_msats": int(delta),
"node_balance_msats": int(node_balance),
"lnbits_balance_msats": int(total_balance),
}
except Exception:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not audit balance.",
)
@admin_router.get( @admin_router.get(
@@ -104,30 +90,6 @@ async def api_restart_server() -> dict[str, str]:
return {"status": "Success"} return {"status": "Success"}
@admin_router.put(
"/api/v1/topup",
name="Topup",
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)
except Exception:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="VoidWallet active"
)
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
@admin_router.get( @admin_router.get(
"/api/v1/backup", "/api/v1/backup",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
+20 -15
View File
@@ -25,22 +25,19 @@ from lnbits.core.models import (
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_user_exists, check_user_exists,
get_key_type,
require_admin_key, require_admin_key,
require_invoice_key,
) )
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from ..crud import ( from ..services import create_user_account, perform_lnurlauth
create_account,
create_wallet,
)
from ..services import perform_lnurlauth
# backwards compatibility for extension # backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions # TODO: remove api_payment and pay_invoice imports from extensions
@@ -67,19 +64,21 @@ async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWalle
async def api_create_account(data: CreateWallet) -> Wallet: async def api_create_account(data: CreateWallet) -> Wallet:
if not settings.new_accounts_allowed: if not settings.new_accounts_allowed:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.", detail="Account creation is disabled.",
) )
account = await create_account() account = await create_user_account(wallet_name=data.name)
return await create_wallet(user_id=account.id, wallet_name=data.name) return account.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}") @api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_lnurlscan(
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try: try:
url = str(lnurl_decode(code)) url = str(lnurl_decode(code))
domain = urlparse(url).netloc domain = urlparse(url).netloc
except Exception: except Exception as exc:
# parse internet identifier (user@domain.com) # parse internet identifier (user@domain.com)
name_domain = code.split("@") name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2: if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
@@ -94,7 +93,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl" status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) ) from exc
# params is what will be returned to the client # params is what will be returned to the client
params: Dict = {"domain": domain} params: Dict = {"domain": domain}
@@ -119,14 +118,14 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
try: try:
data = json.loads(r.text) data = json.loads(r.text)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={ detail={
"domain": domain, "domain": domain,
"message": f"got invalid response '{r.text[:200]}'", "message": f"got invalid response '{r.text[:200]}'",
}, },
) ) from exc
try: try:
tag: str = data.get("tag") tag: str = data.get("tag")
@@ -185,7 +184,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
"domain": domain, "domain": domain,
"message": f"lnurl JSON response invalid: {exc}", "message": f"lnurl JSON response invalid: {exc}",
}, },
) ) from exc
return params return params
@@ -202,6 +201,12 @@ async def api_perform_lnurlauth(
return "" return ""
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies") @api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]: async def api_list_currencies_available() -> List[str]:
return allowed_currencies() return allowed_currencies()
+54 -49
View File
@@ -12,6 +12,7 @@ from starlette.status import (
HTTP_500_INTERNAL_SERVER_ERROR, HTTP_500_INTERNAL_SERVER_ERROR,
) )
from lnbits.core.services import create_user_account
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@@ -23,8 +24,6 @@ from lnbits.helpers import (
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
from ..crud import ( from ..crud import (
create_account,
create_user,
get_account, get_account,
get_account_by_email, get_account_by_email,
get_account_by_username_or_email, get_account_by_username_or_email,
@@ -68,11 +67,11 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id) return _auth_success_response(user.username, user.id)
except HTTPException as e: except HTTPException as exc:
raise e raise exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/usr", description="Login via the User ID") @auth_router.post("/usr", description="Login via the User ID")
@@ -86,11 +85,11 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.") raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id) return _auth_success_response(user.username or "", user.id)
except HTTPException as e: except HTTPException as exc:
raise e raise exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
@@ -124,16 +123,16 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
user_id = decrypt_internal_message(provider_sso.state) user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None) request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id) return await _handle_sso_login(userinfo, user_id)
except HTTPException as e: except HTTPException as exc:
raise e raise exc
except ValueError as e: except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(e)) raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException( raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.", f"Cannot authenticate user with {provider} Auth.",
) ) from exc
@auth_router.post("/logout") @auth_router.post("/logout")
@@ -166,14 +165,18 @@ async def register(data: CreateUser) -> JSONResponse:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.") raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
try: try:
user = await create_user(data) user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username) return _auth_success_response(user.username)
except ValueError as e: except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(e)) raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.") raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
) from exc
@auth_router.put("/password") @auth_router.put("/password")
@@ -189,13 +192,13 @@ async def update_password(
try: try:
return await update_user_password(data) return await update_user_password(data)
except AssertionError as e: except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(e)) raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException( raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password." HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) ) from exc
@auth_router.put("/update") @auth_router.put("/update")
@@ -211,11 +214,13 @@ async def update(
try: try:
return await update_account(user.id, data.username, None, data.config) return await update_account(user.id, data.username, None, data.config)
except AssertionError as e: except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(e)) raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.") raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
) from exc
@auth_router.put("/first_install") @auth_router.put("/first_install")
@@ -237,13 +242,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
await update_user_password(super_user) await update_user_password(super_user)
settings.first_install = False settings.first_install = False
return _auth_success_response(username=super_user.username) return _auth_success_response(username=super_user.username)
except AssertionError as e: except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(e)) raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as e: except Exception as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException( raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password." HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) ) from exc
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None): async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
@@ -270,7 +275,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
else: else:
if not settings.new_accounts_allowed: if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.") raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_account(email=email, user_config=user_config) user = await create_user_account(email=email, user_config=user_config)
if not user: if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.") raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
@@ -316,16 +321,16 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
logger.warning(f"{provider} auth allowed but not configured.") logger.warning(f"{provider} auth allowed but not configured.")
return None return None
SSOProviderClass = _find_auth_provider_class(provider) sso_provider_class = _find_auth_provider_class(provider)
ssoProvider = SSOProviderClass( sso_provider = sso_provider_class(
client_id, client_secret, None, allow_insecure_http=True client_id, client_secret, None, allow_insecure_http=True
) )
if ( if (
discovery_url discovery_url
and getattr(ssoProvider, "discovery_url", discovery_url) != discovery_url and getattr(sso_provider, "discovery_url", discovery_url) != discovery_url
): ):
ssoProvider.discovery_url = discovery_url sso_provider.discovery_url = discovery_url
return ssoProvider return sso_provider
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
@@ -337,9 +342,9 @@ def _find_auth_provider_class(provider: str) -> Callable:
for module in sso_modules: for module in sso_modules:
try: try:
provider_module = importlib.import_module(f"{module}.{provider}") provider_module = importlib.import_module(f"{module}.{provider}")
ProviderClass = getattr(provider_module, f"{provider.title()}SSO") provider_class = getattr(provider_module, f"{provider.title()}SSO")
if ProviderClass: if provider_class:
return ProviderClass return provider_class
except Exception: except Exception:
pass pass
+320 -114
View File
@@ -1,6 +1,6 @@
from http import HTTPStatus
from typing import ( from typing import (
List, List,
Optional,
) )
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
@@ -9,41 +9,44 @@ from fastapi import (
Depends, Depends,
HTTPException, HTTPException,
) )
from fastapi import (
status as HTTPStatus,
)
from loguru import logger from loguru import logger
from lnbits.core.db import core_app_extra from lnbits.core.extensions.extension_manager import (
from lnbits.core.helpers import ( activate_extension,
migrate_extension_database, deactivate_extension,
stop_extension_background_work, install_extension,
uninstall_extension,
) )
from lnbits.core.models import ( from lnbits.core.extensions.models import (
User,
)
from lnbits.decorators import (
check_access_token,
check_admin,
)
from lnbits.extension_manager import (
CreateExtension, CreateExtension,
Extension, Extension,
ExtensionConfig,
ExtensionRelease, ExtensionRelease,
InstallableExtension, InstallableExtension,
fetch_github_release_config, PayToEnableInfo,
fetch_release_payment_info, ReleasePaymentInfo,
get_valid_extensions, UserExtensionInfo,
)
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import (
check_admin,
check_user_exists,
) )
from lnbits.settings import settings
from ..crud import ( from ..crud import (
add_installed_extension,
delete_dbversion, delete_dbversion,
delete_installed_extension,
drop_extension_db, drop_extension_db,
get_dbversions, get_dbversions,
get_installed_extension, get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_user_extension,
update_user_extension_extra,
) )
extension_router = APIRouter( extension_router = APIRouter(
@@ -52,12 +55,8 @@ extension_router = APIRouter(
) )
@extension_router.post("") @extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension( async def api_install_extension(data: CreateExtension):
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
release = await InstallableExtension.get_extension_release( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
@@ -77,67 +76,227 @@ async def api_install_extension(
) )
try: try:
installed_ext = await get_installed_extension(data.ext_id) extension = await install_extension(ext_info)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive() except Exception as exc:
logger.warning(exc)
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)
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)
if data.ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [data.ext_id]
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext_info.notify_upgrade()
return extension
except AssertionError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
except Exception as ex:
logger.warning(ex)
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=( detail=detail,
f"Failed to install extension {ext_info.id} " ) from exc
f"({ext_info.installed_version})."
), 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)])
async def api_extension_details(
ext_id: str,
details_link: str,
):
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 ExtensionRelease.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)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"Failed to get details for extension {ext_id}.",
) from exc
@extension_router.put("/{ext_id}/sell")
async def api_update_pay_to_enable(
ext_id: str,
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)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
) from exc
@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 Extension.get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
if user.admin or not ext.requires_payment:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
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):
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)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.pay_to_enable and ext.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,
payment_hash=user_ext.extra.payment_hash_to_enable,
) )
if not payment_status.paid:
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
@extension_router.delete("/{ext_id}") user_ext.extra.paid_to_enable = True
async def api_uninstall_extension( await update_user_extension_extra(user.id, ext_id, user_ext.extra)
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
installable_extensions = await InstallableExtension.get_installable_extensions()
extensions = [e for e in installable_extensions if e.id == ext_id] await update_user_extension(user_id=user.id, extension=ext_id, active=True)
if len(extensions) == 0: return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except HTTPException as exc:
raise exc from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to enable '{ext_id}' "),
) from exc
@extension_router.put("/{ext_id}/disable")
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 Extension.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
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Activating extension: '{ext_id}'.")
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
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}'."),
) from exc
@extension_router.put("/{ext_id}/deactivate", dependencies=[Depends(check_admin)])
async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Deactivating extension: '{ext_id}'.")
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to deactivate '{ext_id}'."),
) from exc
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}", detail=f"Unknown extension id: {ext_id}",
) )
installed_extensions = await get_installed_extensions()
# check that other extensions do not depend on this one # check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in get_valid_extensions()]: for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
installed_ext = next( installed_ext = next(
(ext for ext in installable_extensions if ext.id == valid_ext_id), None (ext for ext in installed_extensions if ext.id == valid_ext_id), None
) )
if installed_ext and ext_id in installed_ext.dependencies: if installed_ext and ext_id in installed_ext.dependencies:
raise HTTPException( raise HTTPException(
@@ -149,25 +308,18 @@ async def api_uninstall_extension(
) )
try: try:
# call stop while the old routes are still active await uninstall_extension(ext_id)
await stop_extension_background_work(ext_id, user.id, access_token)
if ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [ext_id]
for ext_info in extensions:
ext_info.clean_extension_files()
await delete_installed_extension(ext_id=ext_info.id)
logger.success(f"Extension '{ext_id}' uninstalled.") logger.success(f"Extension '{ext_id}' uninstalled.")
except Exception as ex: return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) ) from exc
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)]) @extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str): async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
try: try:
extension_releases: List[ExtensionRelease] = ( extension_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
@@ -184,44 +336,95 @@ async def get_extension_releases(ext_id: str):
return extension_releases return extension_releases
except Exception as ex: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) ) from exc
@extension_router.put("/invoice", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/invoice/install", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension): async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo:
try: try:
assert data.cost_sats, "A non-zero amount must be specified" assert (
ext_id == data.ext_id
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
assert data.cost_sats, "A non-zero amount must be specified."
release = await InstallableExtension.get_extension_release( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
assert release, "Release not found" assert release, "Release not found."
assert release.pay_link, "Pay link not found for release" assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info( payment_info = await release.fetch_release_payment_info(data.cost_sats)
release.pay_link, data.cost_sats
) assert payment_info and payment_info.payment_request, "Cannot request invoice."
assert payment_info and payment_info.payment_request, "Cannot request invoice"
invoice = bolt11_decode(payment_info.payment_request) invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing" assert invoice.amount_msat is not None, "Invoic amount is missing."
invoice_amount = int(invoice.amount_msat / 1000) invoice_amount = int(invoice.amount_msat / 1000)
assert ( assert (
invoice_amount == data.cost_sats invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}." ), f"Wrong invoice amount: {invoice_amount}."
assert ( assert (
payment_info.payment_hash == invoice.payment_hash payment_info.payment_hash == invoice.payment_hash
), "Wroong invoice payment hash" ), "Wrong invoice payment hash."
return payment_info return payment_info
except AssertionError as e: except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e)) raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as ex: except Exception as exc:
logger.warning(ex) logger.warning(exc)
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice") raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
) from exc
@extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
):
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)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
) from exc
@extension_router.get( @extension_router.get(
@@ -230,7 +433,7 @@ async def get_extension_invoice(data: CreateExtension):
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
config = await fetch_github_release_config(org, repo, tag_name) config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
if not config: if not config:
return {} return {}
@@ -239,10 +442,10 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
"is_version_compatible": config.is_version_compatible(), "is_version_compatible": config.is_version_compatible(),
"warning": config.warning, "warning": config.warning,
} }
except Exception as ex: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) ) from exc
@extension_router.delete( @extension_router.delete(
@@ -260,12 +463,15 @@ async def delete_extension_db(ext_id: str):
await drop_extension_db(ext_id=ext_id) await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id) await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{ext_id}'") logger.success(f"Database removed for extension '{ext_id}'")
return SimpleStatus(
success=True, message=f"DB deleted for '{ext_id}' extension."
)
except HTTPException as ex: except HTTPException as ex:
logger.error(ex) logger.error(ex)
raise ex raise ex
except Exception as ex: except Exception as exc:
logger.error(ex) logger.error(exc)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'", detail=f"Cannot delete data for extension '{ext_id}'",
) ) from exc
+101 -196
View File
@@ -1,40 +1,34 @@
import asyncio
import sys
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Annotated, List, Optional, Union from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse from urllib.parse import urlencode, urlparse
from fastapi import Cookie, Depends, Query, Request, status import httpx
from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.db import core_app_extra, db from lnbits.core.extensions.models import Extension, InstallableExtension
from lnbits.core.helpers import to_valid_user_id from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.services import create_invoice
from lnbits.decorators import check_admin, check_user_exists from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer, url_for from lnbits.helpers import template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import ( from ..crud import (
create_account, create_account,
create_wallet, create_wallet,
get_balance_check,
get_dbversions, get_dbversions,
get_inactive_extensions,
get_installed_extensions, get_installed_extensions,
get_user, get_user,
save_balance_notify,
update_installed_extension_state,
update_user_extension,
) )
from ..services import pay_invoice, redeem_lnurl_withdraw
generic_router = APIRouter( generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False tags=["Core NON-API Website Routes"], include_in_schema=False
@@ -78,21 +72,10 @@ async def robots():
return HTMLResponse(content=data, media_type="text/plain") return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get( @generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
"/extensions", name="install.extensions", response_class=HTMLResponse async def extensions(request: Request, user: User = Depends(check_user_exists)):
)
async def extensions_install(
request: Request,
user: User = Depends(check_user_exists),
activate: str = Query(None),
deactivate: str = Query(None),
enable: str = Query(None),
disable: str = Query(None),
):
await toggle_extension(enable, disable, user.id)
try: try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions() installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions() installable_exts = await InstallableExtension.get_installable_extensions()
@@ -105,6 +88,11 @@ async def extensions_install(
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None) installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext: if installed_ext:
e.installed_release = installed_ext.installed_release 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
# use the installed extension values # use the installed extension values
e.name = installed_ext.name e.name = installed_ext.name
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
@@ -116,30 +104,10 @@ async def extensions_install(
installed_exts_ids = [] installed_exts_ids = []
try: try:
ext_id = activate or deactivate all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
all_extensions = get_valid_extensions() inactive_extensions = [
ext = next((e for e in all_extensions if e.code == ext_id), None) e.id for e in await get_installed_extensions(active=False)
if ext_id and user.admin: ]
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [deactivate]
elif activate:
# 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 = list(
filter(
lambda e: e != activate, settings.lnbits_deactivated_extensions
)
)
await update_installed_extension_state(
ext_id=ext_id, active=activate is not None
)
all_ext_ids = [ext.code for ext in all_extensions]
inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions() db_version = await get_dbversions()
extensions = [ extensions = [
{ {
@@ -161,6 +129,8 @@ async def extensions_install(
"installedRelease": ( "installedRelease": (
dict(ext.installed_release) if ext.installed_release else None 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 for ext in installable_exts
] ]
@@ -176,9 +146,11 @@ async def extensions_install(
"extensions": extensions, "extensions": extensions,
}, },
) )
except Exception as e: except Exception as exc:
logger.warning(e) logger.warning(exc)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@generic_router.get( @generic_router.get(
@@ -206,7 +178,7 @@ async def wallet(
user_wallet = user.get_wallet(wallet_id) user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted: if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"} request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
) )
resp = template_renderer().TemplateResponse( resp = template_renderer().TemplateResponse(
@@ -243,115 +215,6 @@ async def account(
) )
@generic_router.get("/withdraw", response_class=JSONResponse)
async def lnurl_full_withdraw(request: Request):
usr_param = request.query_params.get("usr")
if not usr_param:
return {"status": "ERROR", "reason": "usr parameter not provided."}
user = await get_user(usr_param)
if not user:
return {"status": "ERROR", "reason": "User does not exist."}
wal_param = request.query_params.get("wal")
if not wal_param:
return {"status": "ERROR", "reason": "wal parameter not provided."}
wallet = user.get_wallet(wal_param)
if not wallet:
return {"status": "ERROR", "reason": "Wallet does not exist."}
return {
"tag": "withdrawRequest",
"callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
"k1": "0",
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": (
f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}"
),
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
}
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
async def lnurl_full_withdraw_callback(request: Request):
usr_param = request.query_params.get("usr")
if not usr_param:
return {"status": "ERROR", "reason": "usr parameter not provided."}
user = await get_user(usr_param)
if not user:
return {"status": "ERROR", "reason": "User does not exist."}
wal_param = request.query_params.get("wal")
if not wal_param:
return {"status": "ERROR", "reason": "wal parameter not provided."}
wallet = user.get_wallet(wal_param)
if not wallet:
return {"status": "ERROR", "reason": "Wallet does not exist."}
pr = request.query_params.get("pr")
if not pr:
return {"status": "ERROR", "reason": "payment_request not provided."}
async def pay():
try:
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
except Exception:
pass
asyncio.create_task(pay())
balance_notify = request.query_params.get("balanceNotify")
if balance_notify:
await save_balance_notify(wallet.id, balance_notify)
return {"status": "OK"}
@generic_router.get("/withdraw/notify/{service}")
async def lnurl_balance_notify(request: Request, service: str):
wal_param = request.query_params.get("wal")
if not wal_param:
return {"status": "ERROR", "reason": "wal parameter not provided."}
bc = await get_balance_check(wal_param, service)
if bc:
await redeem_lnurl_withdraw(bc.wallet, bc.url)
@generic_router.get(
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
)
async def lnurlwallet(request: Request):
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
assert user, "Newly created user not found."
wallet = await create_wallet(user_id=user.id, conn=conn)
lightning_param = request.query_params.get("lightning")
if not lightning_param:
return {"status": "ERROR", "reason": "lightning parameter not provided."}
asyncio.create_task(
redeem_lnurl_withdraw(
wallet.id,
lightning_param,
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
)
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@generic_router.get("/service-worker.js") @generic_router.get("/service-worker.js")
async def service_worker(request: Request): async def service_worker(request: Request):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
@@ -452,8 +315,8 @@ async def node(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_node_ui: if not settings.lnbits_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE) raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
WALLET = get_wallet_class() funding_source = get_funding_source()
_, balance = await WALLET.status() _, balance = await funding_source.status()
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
@@ -472,8 +335,8 @@ async def node_public(request: Request):
if not settings.lnbits_public_node_ui: if not settings.lnbits_public_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE) raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
WALLET = get_wallet_class() funding_source = get_funding_source()
_, balance = await WALLET.status() _, balance = await funding_source.status()
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
@@ -486,12 +349,12 @@ async def node_public(request: Request):
@generic_router.get("/admin", response_class=HTMLResponse) @generic_router.get("/admin", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)): async def admin_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui: if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
WALLET = get_wallet_class() funding_source = get_funding_source()
_, balance = await WALLET.status() _, balance = await funding_source.status()
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
@@ -505,36 +368,78 @@ async def index(request: Request, user: User = Depends(check_admin)):
) )
@generic_router.get("/users", response_class=HTMLResponse)
async def users_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return template_renderer().TemplateResponse(
"users/index.html",
{
"request": request,
"user": user.dict(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
},
)
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
async def hex_to_uuid4(hex_value: str): async def hex_to_uuid4(hex_value: str):
try: try:
user_id = to_valid_user_id(hex_value).hex user_id = to_valid_user_id(hex_value).hex
return RedirectResponse(url=f"/wallet?usr={user_id}") return RedirectResponse(url=f"/wallet?usr={user_id}")
except Exception as e: except Exception as exc:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
if extension_to_enable and extension_to_disable:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension." status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) ) from exc
# check if extension exists
if extension_to_enable or extension_to_disable: @generic_router.get("/lnurlwallet", response_class=RedirectResponse)
ext = extension_to_enable or extension_to_disable async def lnurlwallet(request: Request):
if ext not in [e.code for e in get_valid_extensions()]: """
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( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist." 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_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)
if extension_to_enable: res2 = await client.get(callback, timeout=2)
logger.info(f"Enabling extension: {extension_to_enable} for user {user_id}") res2.raise_for_status()
await update_user_extension(
user_id=user_id, extension=extension_to_enable, active=True return RedirectResponse(
) f"/wallet?usr={account.id}&wal={wallet.id}",
elif extension_to_disable: )
logger.info(f"Disabling extension: {extension_to_disable} for user {user_id}")
await update_user_extension(
user_id=user_id, extension=extension_to_disable, active=False
)
+7 -5
View File
@@ -27,8 +27,8 @@ from ...utils.cache import cache
def require_node(): def require_node():
NODE = get_node_class() node_class = get_node_class()
if not NODE: if not node_class:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED, status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API", detail="Active backend does not implement Node API",
@@ -38,7 +38,7 @@ def require_node():
status_code=HTTPStatus.SERVICE_UNAVAILABLE, status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled", detail="Not enabled",
) )
return NODE return node_class
def check_public(): def check_public():
@@ -195,5 +195,7 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
try: try:
r.raise_for_status() r.raise_for_status()
return r.json()["noderank"] return r.json()["noderank"]
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=404, detail="Node not found on 1ml.com") raise HTTPException(
status_code=404, detail="Node not found on 1ml.com"
) from exc
+75 -107
View File
@@ -13,6 +13,7 @@ from fastapi import (
Depends, Depends,
Header, Header,
HTTPException, HTTPException,
Query,
Request, Request,
) )
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -25,22 +26,20 @@ from lnbits.core.models import (
CreateInvoice, CreateInvoice,
CreateLnurl, CreateLnurl,
DecodePayment, DecodePayment,
KeyType,
Payment, Payment,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
Query,
Wallet, Wallet,
WalletType,
) )
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi, url_for from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@@ -52,16 +51,12 @@ from ..crud import (
get_payments_paginated, get_payments_paginated,
get_standalone_payment, get_standalone_payment,
get_wallet_for_key, get_wallet_for_key,
save_balance_check,
update_pending_payments,
) )
from ..services import ( from ..services import (
InvoiceFailure,
PaymentFailure,
check_transaction_status,
create_invoice, create_invoice,
fee_reserve_total, fee_reserve_total,
pay_invoice, pay_invoice,
update_pending_payments,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@@ -77,12 +72,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments( return await get_payments(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -96,12 +91,12 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"), group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters) return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get( @payment_router.get(
@@ -113,12 +108,12 @@ async def api_payments_history(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_paginated( async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated( page = await get_payments_paginated(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -134,55 +129,47 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
if data.description_hash: if data.description_hash:
try: try:
description_hash = bytes.fromhex(data.description_hash) description_hash = bytes.fromhex(data.description_hash)
except ValueError: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string", detail="'description_hash' must be a valid hex string",
) ) from exc
if data.unhashed_description: if data.unhashed_description:
try: try:
unhashed_description = bytes.fromhex(data.unhashed_description) unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string", detail="'unhashed_description' must be a valid hex string",
) ) from exc
# do not save memo if description_hash or unhashed_description is set # do not save memo if description_hash or unhashed_description is set
memo = "" memo = ""
async with db.connect() as conn: async with db.connect() as conn:
try: payment_hash, payment_request = await create_invoice(
payment_hash, payment_request = await create_invoice( wallet_id=wallet.id,
wallet_id=wallet.id, amount=data.amount,
amount=data.amount, memo=memo,
memo=memo, currency=data.unit,
currency=data.unit, description_hash=description_hash,
description_hash=description_hash, unhashed_description=unhashed_description,
unhashed_description=unhashed_description, expiry=data.expiry,
expiry=data.expiry, extra=data.extra,
extra=data.extra, webhook=data.webhook,
webhook=data.webhook, internal=data.internal,
internal=data.internal, conn=conn,
conn=conn, )
) # NOTE: we get the checking_id with a seperate query because create_invoice
# 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
# does not return it and it would be a big hustle to change its return type # (used across extensions)
# (used across extensions) payment_db = await get_standalone_payment(payment_hash, conn=conn)
payment_db = await get_standalone_payment(payment_hash, conn=conn) assert payment_db is not None, "payment not found"
assert payment_db is not None, "payment not found" checking_id = payment_db.checking_id
checking_id = payment_db.checking_id
except InvoiceFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback: if data.lnurl_callback:
if data.lnurl_balance_check is not None:
await save_balance_check(wallet.id, data.lnurl_balance_check)
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
@@ -190,11 +177,6 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
data.lnurl_callback, data.lnurl_callback,
params={ params={
"pr": payment_request, "pr": payment_request,
"balanceNotify": url_for(
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
external=True,
wal=wallet.id,
),
}, },
timeout=10, timeout=10,
) )
@@ -213,32 +195,9 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
return { return {
"payment_hash": invoice.payment_hash, "payment_hash": invoice.payment_hash,
"payment_request": payment_request, "payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients: # maintain backwards compatibility with API clients:
"checking_id": checking_id, "checking_id": checking_id,
"lnurl_response": lnurl_response,
}
async def api_payments_pay_invoice(
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
):
try:
payment_hash = await pay_invoice(
wallet_id=wallet.id, payment_request=bolt11, extra=extra
)
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
except PaymentFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
} }
@@ -254,23 +213,37 @@ async def api_payments_pay_invoice(
field to supply the BOLT11 invoice to be paid. field to supply the BOLT11 invoice to be paid.
""", """,
status_code=HTTPStatus.CREATED, status_code=HTTPStatus.CREATED,
responses={
400: {"description": "Invalid BOLT11 string or missing fields."},
401: {"description": "Invoice (or Admin) key required."},
520: {"description": "Payment or Invoice error."},
},
) )
async def api_payments_create( async def api_payments_create(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
invoiceData: CreateInvoice = Body(...), invoice_data: CreateInvoice = Body(...),
): ):
if invoiceData.out is True and wallet.wallet_type == WalletType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoiceData.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given", detail="BOLT11 string is invalid or not given",
) )
return await api_payments_pay_invoice(
invoiceData.bolt11, wallet.wallet, invoiceData.extra payment_hash = await pay_invoice(
) # admin key wallet_id=wallet.wallet.id,
elif not invoiceData.out: payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
elif not invoice_data.out:
# invoice key # invoice key
return await api_payments_create_invoice(invoiceData, wallet.wallet) return await api_payments_create_invoice(invoice_data, wallet.wallet)
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, status_code=HTTPStatus.UNAUTHORIZED,
@@ -316,11 +289,11 @@ async def api_payments_pay_lnurl(
if r.is_error: if r.is_error:
raise httpx.ConnectError("LNURL callback connection error") raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status() r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError) as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.", detail=f"Failed to connect to {domain}.",
) ) from exc
params = json.loads(r.text) params = json.loads(r.text)
if params.get("status") == "ERROR": if params.get("status") == "ERROR":
@@ -386,7 +359,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
api_invoice_listeners[uid] = payment_queue api_invoice_listeners[uid] = payment_queue
try: try:
while True: while settings.lnbits_running:
if await request.is_disconnected(): if await request.is_disconnected():
await request.close() await request.close()
break break
@@ -404,10 +377,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@payment_router.get("/sse") @payment_router.get("/sse")
async def api_payments_sse( async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
): ):
return EventSourceResponse( return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet), subscribe_wallet_invoices(request, key_info.wallet),
ping=20, ping=20,
media_type="text/event-stream", media_type="text/event-stream",
) )
@@ -415,10 +388,10 @@ async def api_payments_sse(
# TODO: refactor this route into a public and admin one # TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}") @payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys # We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not # If a valid key is given, we also return the field "details", otherwise not
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
payment = await get_standalone_payment( payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None payment_hash, wallet_id=wallet.id if wallet else None
@@ -427,21 +400,14 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment( if payment.success:
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment} return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
try: try:
await payment.check_status() status = await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}
@@ -449,11 +415,12 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": not payment.pending, "paid": payment.success,
"status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }
return {"paid": not payment.pending, "preimage": payment.preimage} return {"paid": payment.success, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK) @payment_router.post("/decode", status_code=HTTPStatus.OK)
@@ -465,9 +432,10 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
return JSONResponse({"domain": url}) return JSONResponse({"domain": url})
else: else:
invoice = bolt11.decode(payment_str) invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data) filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
return JSONResponse(filtered_data)
except Exception as exc: except Exception as exc:
return JSONResponse( return JSONResponse(
{"message": f"Failed to decode: {str(exc)}"}, {"message": f"Failed to decode: {exc!s}"},
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
+5 -4
View File
@@ -20,17 +20,18 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
elif not payment.pending: # TODO: refactor to use PaymentState
if payment.success:
return {"status": "paid"} return {"status": "paid"}
try: try:
invoice = bolt11.decode(payment.bolt11) invoice = bolt11.decode(payment.bolt11)
if invoice.has_expired(): if invoice.has_expired():
return {"status": "expired"} return {"status": "expired"}
except Exception: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
) ) from exc
payment_queue = asyncio.Queue(0) payment_queue = asyncio.Queue(0)
@@ -50,7 +51,7 @@ async def api_public_payment_longpolling(payment_hash):
cancel_scope.cancel() cancel_scope.cancel()
cancel_scope = asyncio.create_task(payment_info_receiver()) cancel_scope = asyncio.create_task(payment_info_receiver())
asyncio.create_task(timeouter(cancel_scope)) asyncio.create_task(timeouter(cancel_scope)) # noqa: RUF006
if response: if response:
return response return response
+6 -6
View File
@@ -38,10 +38,10 @@ async def api_create_tinyurl(
if tinyurl.wallet == wallet.wallet.id: if tinyurl.wallet == wallet.wallet.id:
return tinyurl return tinyurl
return await create_tinyurl(url, endless, wallet.wallet.id) return await create_tinyurl(url, endless, wallet.wallet.id)
except Exception: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl" status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
) ) from exc
@tinyurl_router.get( @tinyurl_router.get(
@@ -60,10 +60,10 @@ async def api_get_tinyurl(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided." status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
) )
except Exception: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl" status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
) ) from exc
@tinyurl_router.delete( @tinyurl_router.delete(
@@ -83,10 +83,10 @@ async def api_delete_tinyurl(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided." status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
) )
except Exception: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete" status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
) ) from exc
@tinyurl_router.get( @tinyurl_router.get(
+159
View File
@@ -0,0 +1,159 @@
from http import HTTPStatus
from typing import List
from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import (
delete_account,
delete_wallet,
force_delete_wallet,
get_accounts,
get_wallet,
get_wallets,
update_admin_settings,
)
from lnbits.core.models import (
Account,
AccountFilters,
CreateTopup,
User,
Wallet,
)
from lnbits.core.services import 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.settings import EditableSettings, settings
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
@users_router.get(
"/user",
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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch users. {exc!s}",
) from exc
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
@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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not update admin settings. {exc}",
) from exc
@users_router.get("/user/{user_id}/wallet")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
try:
return await get_wallets(user_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch user wallets. {exc}",
) from exc
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
@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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
@users_router.put(
"/topup",
name="Topup",
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")
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
+10 -11
View File
@@ -8,13 +8,13 @@ from fastapi import (
from lnbits.core.models import ( from lnbits.core.models import (
CreateWallet, CreateWallet,
KeyType,
Wallet, Wallet,
WalletType,
) )
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
require_admin_key, require_admin_key,
require_invoice_key,
) )
from ..crud import ( from ..crud import (
@@ -27,15 +27,14 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("") @wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
if wallet.wallet_type == WalletType.admin: res = {
return { "name": wallet.wallet.name,
"id": wallet.wallet.id, "balance": wallet.wallet.balance_msat,
"name": wallet.wallet.name, }
"balance": wallet.wallet.balance_msat, if wallet.key_type == KeyType.admin:
} res["id"] = wallet.wallet.id
else: return res
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@wallet_router.put("/{new_name}") @wallet_router.put("/{new_name}")
+34 -17
View File
@@ -6,8 +6,10 @@ from urllib.parse import unquote, urlparse
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
HTTPException,
Request, Request,
) )
from loguru import logger
from lnbits.core.models import ( from lnbits.core.models import (
CreateWebPushSubscription, CreateWebPushSubscription,
@@ -33,20 +35,27 @@ async def api_create_webpush_subscription(
data: CreateWebPushSubscription, data: CreateWebPushSubscription,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> WebPushSubscription: ) -> WebPushSubscription:
subscription = json.loads(data.subscription) try:
endpoint = subscription["endpoint"] subscription = json.loads(data.subscription)
host = urlparse(str(request.url)).netloc endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user) subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription: if subscription:
return subscription return subscription
else: else:
return await create_webpush_subscription( return await create_webpush_subscription(
endpoint, endpoint,
wallet.wallet.user, wallet.wallet.user,
data.subscription, data.subscription,
host, host,
) )
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
"Cannot create webpush notification",
) from exc
@webpush_router.delete("", status_code=HTTPStatus.OK) @webpush_router.delete("", status_code=HTTPStatus.OK)
@@ -54,7 +63,15 @@ async def api_delete_webpush_subscription(
request: Request, request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
endpoint = unquote( try:
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8") endpoint = unquote(
) base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
await delete_webpush_subscription(endpoint, wallet.wallet.user) )
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
return {"count": count}
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
"Cannot delete webpush notification",
) from exc
+9 -7
View File
@@ -4,9 +4,11 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
) )
from lnbits.settings import settings
from ..services import ( from ..services import (
websocketManager, websocket_manager,
websocketUpdater, websocket_updater,
) )
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"]) websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@@ -14,18 +16,18 @@ websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@websocket_router.websocket("/{item_id}") @websocket_router.websocket("/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str): async def websocket_connect(websocket: WebSocket, item_id: str):
await websocketManager.connect(websocket, item_id) await websocket_manager.connect(websocket, item_id)
try: try:
while True: while settings.lnbits_running:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
websocketManager.disconnect(websocket) websocket_manager.disconnect(websocket)
@websocket_router.post("/{item_id}") @websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str): async def websocket_update_post(item_id: str, data: str):
try: try:
await websocketUpdater(item_id, data) await websocket_updater(item_id, data)
return {"sent": True, "data": data} return {"sent": True, "data": data}
except Exception: except Exception:
return {"sent": False, "data": data} return {"sent": False, "data": data}
@@ -34,7 +36,7 @@ async def websocket_update_post(item_id: str, data: str):
@websocket_router.get("/{item_id}/{data}") @websocket_router.get("/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str): async def websocket_update_get(item_id: str, data: str):
try: try:
await websocketUpdater(item_id, data) await websocket_updater(item_id, data)
return {"sent": True, "data": data} return {"sent": True, "data": data}
except Exception: except Exception:
return {"sent": False, "data": data} return {"sent": False, "data": data}
+144 -133
View File
@@ -7,14 +7,13 @@ import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from enum import Enum from enum import Enum
from sqlite3 import Row from typing import Any, Generic, Literal, Optional, TypeVar
from typing import Any, Generic, List, Literal, Optional, Type, TypeVar
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import create_engine from sqlalchemy import event
from sqlalchemy_aio.base import AsyncConnection from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY from sqlalchemy.sql import text
from lnbits.settings import settings from lnbits.settings import settings
@@ -24,31 +23,15 @@ SQLITE = "SQLITE"
if settings.lnbits_database_url: if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"): if database_uri.startswith("cockroachdb://"):
DB_TYPE = COCKROACH DB_TYPE = COCKROACH
else: else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
from psycopg2.extensions import DECIMAL, new_type, register_type
def _parse_timestamp(value, _):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
register_type(
new_type(
DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
else: else:
if not os.path.isdir(settings.lnbits_data_folder): if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder) os.mkdir(settings.lnbits_data_folder)
@@ -56,13 +39,21 @@ else:
DB_TYPE = SQLITE DB_TYPE = SQLITE
def compat_timestamp_placeholder(): def compat_timestamp_placeholder(key: str):
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
return "to_timestamp(?)" return f"to_timestamp(:{key})"
elif DB_TYPE == COCKROACH: elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)" return f"cast(:{key} AS timestamp)"
else: else:
return "?" return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime.datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat: class Compat:
@@ -119,15 +110,13 @@ class Compat:
return "BIGINT" return "BIGINT"
return "INT" return "INT"
@property def timestamp_placeholder(self, key: str) -> str:
def timestamp_placeholder(self) -> str: return compat_timestamp_placeholder(key)
return compat_timestamp_placeholder()
class Connection(Compat): class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema): def __init__(self, conn: AsyncConnection, typ, name, schema):
self.conn = conn self.conn = conn
self.txn = txn
self.type = typ self.type = typ
self.name = name self.name = name
self.schema = schema self.schema = schema
@@ -138,48 +127,45 @@ class Connection(Compat):
query = query.replace("?", "%s") query = query.replace("?", "%s")
return query return query
def rewrite_values(self, values): def rewrite_values(self, values: dict) -> dict:
# strip html # strip html
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});") clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
clean_values: dict = {}
# tuple to list and back to tuple for key, raw_value in values.items():
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
if isinstance(raw_value, str): if isinstance(raw_value, str):
values.append(re.sub(CLEANR, "", raw_value)) clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime.datetime): elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp() ts = raw_value.timestamp()
if self.type == SQLITE: if self.type == SQLITE:
values.append(int(ts)) clean_values[key] = int(ts)
else: else:
values.append(ts) clean_values[key] = ts
else: else:
values.append(raw_value) clean_values[key] = raw_value
return tuple(values) return clean_values
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
result = await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) row = result.mappings().all()
return await result.fetchall() result.close()
return row
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
result = await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) row = result.mappings().first()
row = await result.fetchone() result.close()
await result.close()
return row return row
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[List[str]] = None, where: Optional[list[str]] = None,
values: Optional[List[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None, model: Optional[type[TRowModel]] = None,
group_by: Optional[List[str]] = None, group_by: Optional[list[str]] = None,
) -> Page[TRowModel]: ) -> Page[TRowModel]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -203,14 +189,14 @@ class Connection(Compat):
{filters.order_by()} {filters.order_by()}
{filters.pagination()} {filters.pagination()}
""", """,
parsed_values, self.rewrite_values(parsed_values),
) )
if rows: if rows:
# no need for extra query if no pagination is specified # no need for extra query if no pagination is specified
if filters.offset or filters.limit: if filters.offset or filters.limit:
count = await self.fetchone( result = await self.fetchone(
f""" f"""
SELECT COUNT(*) FROM ( SELECT COUNT(*) as count FROM (
{query} {query}
{clause} {clause}
{group_by_string} {group_by_string}
@@ -218,21 +204,22 @@ class Connection(Compat):
""", """,
parsed_values, parsed_values,
) )
count = int(count[0]) count = int(result.get("count", 0))
else: else:
count = len(rows) count = len(rows)
else: else:
count = 0 count = 0
return Page( return Page(
data=[model.from_row(row) for row in rows] if model else rows, data=[model.from_row(row) for row in rows] if model else [],
total=count, total=count,
) )
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
return await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) await self.conn.commit()
return result
class Database(Compat): class Database(Compat):
@@ -245,18 +232,44 @@ class Database(Compat):
self.path = os.path.join( self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3" settings.lnbits_data_folder, f"{self.name}.sqlite3"
) )
database_uri = f"sqlite:///{self.path}" database_uri = f"sqlite+aiosqlite:///{self.path}"
else: else:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"): if self.name.startswith("ext_"):
self.schema = self.name[4:] self.schema = self.name[4:]
else: else:
self.schema = None self.schema = None
self.engine = create_engine( self.engine: AsyncEngine = create_async_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database database_uri, echo=settings.debug_database
) )
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_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 int(
time.mktime(datetime.datetime.strptime(value, f).timetuple())
)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_timestamp,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@@ -265,49 +278,45 @@ class Database(Compat):
async def connect(self): async def connect(self):
await self.lock.acquire() await self.lock.acquire()
try: try:
async with self.engine.connect() as conn: # type: ignore async with self.engine.connect() as conn:
async with conn.begin() as txn: if not conn:
wconn = Connection(conn, txn, self.type, self.name, self.schema) raise Exception("Could not connect to the database")
if self.schema: wconn = Connection(conn, self.type, self.name, self.schema)
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
yield wconn
finally: finally:
self.lock.release() self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchall(query, values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchone(query, values)
row = await result.fetchone()
await result.close()
return row
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[List[str]] = None, where: Optional[list[str]] = None,
values: Optional[List[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None, model: Optional[type[TRowModel]] = None,
group_by: Optional[List[str]] = None, group_by: Optional[list[str]] = None,
) -> Page[TRowModel]: ) -> Page[TRowModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by) return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
async with self.connect() as conn: async with self.connect() as conn:
return await conn.execute(query, values) return await conn.execute(query, values)
@@ -365,13 +374,13 @@ class Operator(Enum):
class FromRowModel(BaseModel): class FromRowModel(BaseModel):
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls(**dict(row)) return cls(**row)
class FilterModel(BaseModel): class FilterModel(BaseModel):
__search_fields__: List[str] = [] __search_fields__: list[str] = []
__sort_fields__: Optional[List[str]] = None __sort_fields__: Optional[list[str]] = None
T = TypeVar("T") T = TypeVar("T")
@@ -388,12 +397,13 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
values: list[Any] model: Optional[type[TFilterModel]]
values: Optional[dict] = None
model: Optional[Type[TFilterModel]]
@classmethod @classmethod
def parse_query(cls, key: str, raw_values: list[Any], model: Type[TFilterModel]): def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
# Key format: # Key format:
# key[operator] # key[operator]
# e.g. name[eq] # e.g. name[eq]
@@ -409,12 +419,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__: if field in model.__fields__:
compare_field = model.__fields__[field] compare_field = model.__fields__[field]
values = [] values: dict = {}
for raw_value in raw_values: for raw_value in raw_values:
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors: if errors:
raise ValidationError(errors=[errors], model=model) raise ValidationError(errors=[errors], model=model)
values.append(validated) values[f"{field}__{i}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@@ -422,15 +432,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property @property
def statement(self): def statement(self):
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime: stmt = []
placeholder = compat_timestamp_placeholder() for key in self.values.keys() if self.values else []:
else: clean_key = key.split("__")[0]
placeholder = "?" if (
if self.op in (Operator.INCLUDE, Operator.EXCLUDE): self.model
placeholders = ", ".join([placeholder] * len(self.values)) and self.model.__fields__[clean_key].type_ == datetime.datetime
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"] ):
else: placeholder = compat_timestamp_placeholder(key)
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values) else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt) return " OR ".join(stmt)
@@ -443,7 +455,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
the values can be validated. Otherwise, make sure to validate the inputs manually. the values can be validated. Otherwise, make sure to validate the inputs manually.
""" """
filters: List[Filter[TFilterModel]] = [] filters: list[Filter[TFilterModel]] = []
search: Optional[str] = None search: Optional[str] = None
offset: Optional[int] = None offset: Optional[int] = None
@@ -452,7 +464,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: Optional[str] = None sortby: Optional[str] = None
direction: Optional[Literal["asc", "desc"]] = None direction: Optional[Literal["asc", "desc"]] = None
model: Optional[Type[TFilterModel]] = None model: Optional[type[TFilterModel]] = None
@root_validator(pre=True) @root_validator(pre=True)
def validate_sortby(cls, values): def validate_sortby(cls, values):
@@ -474,21 +486,18 @@ class Filters(BaseModel, Generic[TFilterModel]):
stmt += f"OFFSET {self.offset}" stmt += f"OFFSET {self.offset}"
return stmt return stmt
def where(self, where_stmts: Optional[List[str]] = None) -> str: def where(self, where_stmts: Optional[list[str]] = None) -> str:
if not where_stmts: if not where_stmts:
where_stmts = [] where_stmts = []
if self.filters: if self.filters:
for page_filter in self.filters: for page_filter in self.filters:
where_stmts.append(page_filter.statement) where_stmts.append(page_filter.statement)
if self.search and self.model: if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
where_stmts.append( where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
elif DB_TYPE == SQLITE: elif DB_TYPE == SQLITE:
where_stmts.append( where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
)
if where_stmts: if where_stmts:
return "WHERE " + " AND ".join(where_stmts) return "WHERE " + " AND ".join(where_stmts)
return "" return ""
@@ -498,12 +507,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}" return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return "" return ""
def values(self, values: Optional[List[str]] = None) -> tuple: def values(self, values: Optional[dict] = None) -> dict:
if not values: if not values:
values = [] values = {}
if self.filters: if self.filters:
for page_filter in self.filters: for page_filter in self.filters:
values.extend(page_filter.values) if page_filter.values:
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model: if self.search and self.model:
values.append(f"%{self.search}%") values["search"] = f"%{self.search}%"
return tuple(values) return values
+113 -181
View File
@@ -1,12 +1,12 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
@@ -15,25 +15,35 @@ from lnbits.core.crud import (
get_account_by_email, get_account_by_email,
get_account_by_username, get_account_by_username,
get_user, get_user,
get_user_active_extensions_ids,
get_wallet_for_key, get_wallet_for_key,
) )
from lnbits.core.models import User, Wallet, WalletType, WalletTypeInfo from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
api_key_query = APIKeyQuery(
name="api-key",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
class KeyChecker(SecurityBase): class KeyChecker(SecurityBase):
def __init__( def __init__(
self, self,
scheme_name: Optional[str] = None,
auto_error: bool = True,
api_key: Optional[str] = None, api_key: Optional[str] = None,
expected_key_type: Optional[KeyType] = None,
): ):
self.scheme_name = scheme_name or self.__class__.__name__ self.auto_error: bool = True
self.auto_error: bool = auto_error self.expected_key_type = expected_key_type
self._key_type: WalletType = WalletType.invoice
self._api_key = api_key self._api_key = api_key
if api_key: if api_key:
openapi_model = APIKey( openapi_model = APIKey(
@@ -49,185 +59,64 @@ class KeyChecker(SecurityBase):
name="X-API-KEY", name="X-API-KEY",
description="Wallet API Key - HEADER", description="Wallet API Key - HEADER",
) )
self.wallet: Optional[Wallet] = None
self.model: APIKey = openapi_model self.model: APIKey = openapi_model
async def __call__(self, request: Request): async def __call__(self, request: Request) -> WalletTypeInfo:
try:
key_value = (
self._api_key
if self._api_key
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
)
# FIXME: Find another way to validate the key. A fetch from DB should be
# avoided here. Also, we should not return the wallet here - thats
# silly. Possibly store it in a Redis DB
wallet = await get_wallet_for_key(key_value, self._key_type)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid key or wallet.",
)
self.wallet = wallet
except KeyError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
)
key_value = (
class WalletInvoiceKeyChecker(KeyChecker): self._api_key
""" if self._api_key
WalletInvoiceKeyChecker will ensure that the provided invoice else request.headers.get("X-API-KEY") or request.query_params.get("api-key")
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
The checker will raise an HTTPException when the key is wrong in some ways.
"""
def __init__(
self,
scheme_name: Optional[str] = None,
auto_error: bool = True,
api_key: Optional[str] = None,
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = WalletType.invoice
class WalletAdminKeyChecker(KeyChecker):
"""
WalletAdminKeyChecker will ensure that the provided admin
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
The checker will raise an HTTPException when the key is wrong in some ways.
"""
def __init__(
self,
scheme_name: Optional[str] = None,
auto_error: bool = True,
api_key: Optional[str] = None,
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = WalletType.admin
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
api_key_query = APIKeyQuery(
name="api-key",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
async def get_key_type(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
) )
for wallet_type, WalletChecker in zip( if not key_value:
[WalletType.admin, WalletType.invoice], raise HTTPException(
[WalletAdminKeyChecker, WalletInvoiceKeyChecker], status_code=HTTPStatus.UNAUTHORIZED,
): detail="No Api Key provided.",
try: )
checker = WalletChecker(api_key=token)
await checker.__call__(r) wallet = await get_wallet_for_key(key_value)
if checker.wallet is None:
raise HTTPException( if not wallet:
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." raise HTTPException(
) status_code=HTTPStatus.NOT_FOUND,
wallet = WalletTypeInfo(wallet_type, checker.wallet) detail="Wallet not found.",
if ( )
wallet.wallet.user != settings.super_user
and wallet.wallet.user not in settings.lnbits_admin_users if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
) and ( raise HTTPException(
settings.lnbits_admin_extensions status_code=HTTPStatus.UNAUTHORIZED,
and r["path"].split("/")[1] in settings.lnbits_admin_extensions detail="Invalid adminkey.",
): )
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, await _check_user_extension_access(wallet.user, request["path"])
detail="User not authorized for this extension.",
) key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
return wallet return WalletTypeInfo(key_type, wallet)
except HTTPException as exc:
if exc.status_code == HTTPStatus.BAD_REQUEST:
raise
elif exc.status_code == HTTPStatus.UNAUTHORIZED:
# we pass this in case it is not an invoice key, nor an admin key,
# and then return NOT_FOUND at the end of this block
pass
else:
raise
except Exception:
raise
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
async def require_admin_key( async def require_admin_key(
r: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query), api_key_query: str = Security(api_key_query),
): ) -> WalletTypeInfo:
token = api_key_header or api_key_query check: KeyChecker = KeyChecker(
api_key=api_key_header or api_key_query,
if not token: expected_key_type=KeyType.admin,
raise HTTPException( )
status_code=HTTPStatus.UNAUTHORIZED, return await check(request)
detail="Admin key required.",
)
wallet = await get_key_type(r, token)
if wallet.wallet_type != 0:
# If wallet type is not admin then return the unauthorized status
# This also covers when the user passes an invalid key type
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
)
else:
return wallet
async def require_invoice_key( async def require_invoice_key(
r: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query), api_key_query: str = Security(api_key_query),
): ) -> WalletTypeInfo:
token = api_key_header or api_key_query check: KeyChecker = KeyChecker(
api_key=api_key_header or api_key_query,
if not token: expected_key_type=KeyType.invoice,
raise HTTPException( )
status_code=HTTPStatus.UNAUTHORIZED, return await check(request)
detail="Invoice (or Admin) key required.",
)
wallet = await get_key_type(r, token)
if (
wallet.wallet_type != WalletType.admin
and wallet.wallet_type != WalletType.invoice
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
else:
return wallet
async def check_access_token( async def check_access_token(
@@ -255,12 +144,24 @@ async def check_user_exists(
user = await get_user(account.id) user = await get_user(account.id)
assert user, "User not found for account." assert user, "User not found for account."
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions: await _check_user_extension_access(user.id, r["path"])
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
return user return user
async def optional_user_id(
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
account = await _get_account_from_token(access_token)
return account.id if account else None
return None
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User: async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users: if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException( raise HTTPException(
@@ -294,9 +195,9 @@ def parse_filters(model: Type[TFilterModel]):
): ):
params = request.query_params params = request.query_params
filters = [] filters = []
for key in params.keys(): for i, key in enumerate(params.keys()):
try: try:
filters.append(Filter.parse_query(key, params.getlist(key), model)) filters.append(Filter.parse_query(key, params.getlist(key), model, i))
except ValueError: except ValueError:
continue continue
@@ -313,9 +214,40 @@ def parse_filters(model: Type[TFilterModel]):
return dependency return dependency
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
"""
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
return SimpleStatus(
success=False, message=f"User not authorized for extension '{ext_id}'."
)
if settings.is_extension_id(ext_id):
ext_ids = await get_user_active_extensions_ids(user_id)
if ext_id not in ext_ids:
return SimpleStatus(
success=False, message=f"User extension '{ext_id}' not enabled."
)
return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, current_path: str):
path = current_path.split("/")
ext_id = path[3] if path[1] == "upgrades" else path[1]
status = await check_user_extension_access(user_id, ext_id)
if not status.success:
raise HTTPException(
HTTPStatus.FORBIDDEN,
status.message,
)
async def _get_account_from_token(access_token): async def _get_account_from_token(access_token):
try: try:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256") payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"): if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub"))) return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"): if "usr" in payload and payload.get("usr"):
@@ -324,10 +256,10 @@ async def _get_account_from_token(access_token):
return await get_account_by_email(str(payload.get("email"))) return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError: except jwt.ExpiredSignatureError as exc:
raise HTTPException( raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"} HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
) ) from exc
except JWTError as e: except jwt.PyJWTError as exc:
logger.debug(e) logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
+117
View File
@@ -0,0 +1,117 @@
import sys
import traceback
from http import HTTPStatus
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from .helpers import template_renderer
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 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)
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 (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
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
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
def register_exception_handler(app: FastAPI):
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.error(f"Exception: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
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
):
logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
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}")
return render_html_error(request, exc) or JSONResponse(
status_code=exc.status_code,
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"{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"{exc.message}, Status: {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
+45 -8
View File
@@ -5,11 +5,13 @@ from pathlib import Path
from typing import Any, List, Optional, Type from typing import Any, List, Optional, Type
import jinja2 import jinja2
import jwt
import shortuuid import shortuuid
from jose import jwt
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.schema import field_schema from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class from lnbits.nodes import get_node_class
from lnbits.requestvars import g from lnbits.requestvars import g
@@ -17,7 +19,19 @@ from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher from lnbits.utils.crypto import AESCipher
from .db import FilterModel from .db import FilterModel
from .extension_manager import get_valid_extensions
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (
"PostgreSQL"
if db_url and db_url.startswith("postgres://")
else (
"CockroachDB"
if db_url and db_url.startswith("cockroachdb://")
else "SQLite"
)
)
def urlsafe_short_hash() -> str: def urlsafe_short_hash() -> str:
@@ -58,6 +72,11 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
settings.lnbits_show_home_page_elements
)
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
t.env.globals["LNBITS_VERSION"] = settings.version t.env.globals["LNBITS_VERSION"] = settings.version
@@ -74,19 +93,21 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None settings.lnbits_node_ui and get_node_class() is not None
) )
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = get_valid_extensions(False) t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
if settings.lnbits_custom_logo: if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.bundle_assets: if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"] t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"] t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
else: else:
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json") vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
with open(vendor_filepath) as vendor_file: with open(vendor_filepath) as vendor_file:
vendor_files = json.loads(vendor_file.read()) vendor_files = json.loads(vendor_file.read())
t.env.globals["INCLUDED_JS"] = vendor_files["js"] t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
@@ -160,19 +181,28 @@ def insert_query(table_name: str, model: BaseModel) -> str:
:param table_name: Name of the table :param table_name: Name of the table
:param model: Pydantic model :param model: Pydantic model
""" """
placeholders = ", ".join(["?"] * len(model.dict().keys())) placeholders = []
for field in model.dict().keys():
placeholders.append(get_placeholder(model, field))
fields = ", ".join(model.dict().keys()) fields = ", ".join(model.dict().keys())
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})" values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str: 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 Generate an update query with placeholders for a given table and model
:param table_name: Name of the table :param table_name: Name of the table
:param model: Pydantic model :param model: Pydantic model
:param where: Where string, default to `WHERE id = ?` :param where: Where string, default to `WHERE id = :id`
""" """
query = ", ".join([f"{field} = ?" for field in model.dict().keys()]) fields = []
for field in model.dict().keys():
placeholder = get_placeholder(model, field)
fields.append(f"{field} = {placeholder}")
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" return f"UPDATE {table_name} SET {query} {where}"
@@ -205,3 +235,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
if not m: if not m:
return None return None
return AESCipher(key=settings.auth_secret_key).decrypt(m) return AESCipher(key=settings.auth_secret_key).decrypt(m)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
return {key: data[key] for key in filter_keys if key in data}
+65 -1
View File
@@ -1 +1,65 @@
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401 from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=200,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"decode",
"encode",
"handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
]
+8 -77
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any, List, Tuple, Union from typing import Any, List, Union
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -29,7 +29,7 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
top_path, *rest = [p for p in full_path.split("/") if p] top_path, *rest = (p for p in full_path.split("/") if p)
headers = scope.get("headers", []) headers = scope.get("headers", [])
# block path for all users if the extension is disabled # block path for all users if the extension is disabled
@@ -45,16 +45,11 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
upgrade_path = next(
(
e
for e in settings.lnbits_upgraded_extensions
if e.endswith(f"/{top_path}")
),
None,
)
# re-route all trafic if the extension has been upgraded # re-route all trafic if the extension has been upgraded
if upgrade_path: if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest) tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}" scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
@@ -118,72 +113,12 @@ class ExtensionsRedirectMiddleware:
return return
req_headers = scope["headers"] if "headers" in scope else [] req_headers = scope["headers"] if "headers" in scope else []
redirect = self._find_redirect(scope["path"], req_headers) redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect: if redirect:
scope["path"] = self._new_path(redirect, scope["path"]) scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
return next(
(
r
for r in settings.lnbits_extensions_redirects
if self._redirect_matches(r, path, req_headers)
),
None,
)
def _redirect_matches(
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
if "from_path" not in redirect:
return False
header_filters = (
redirect["header_filters"] if "header_filters" in redirect else {}
)
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
header_filters, req_headers
)
def _has_headers(
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
for h in filter_headers:
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
return False
return True
def _has_header(
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
) -> bool:
for h in req_headers:
if (
h[0].decode().lower() == header[0].lower()
and h[1].decode() == header[1]
):
return True
return False
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
redirect_path_elements = redirect_path.split("/")
req_path_elements = req_path.split("/")
if len(redirect_path) > len(req_path):
return False
sub_path = req_path_elements[: len(redirect_path_elements)]
return redirect_path == "/".join(sub_path)
def _new_path(self, redirect: dict, req_path: str) -> str:
from_path = redirect["from_path"].split("/")
redirect_to = redirect["redirect_to_path"].split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [
e for e in ([redirect["ext_id"]] + redirect_to + req_tail_path) if e != ""
]
return "/" + "/".join(elements)
def add_ratelimit_middleware(app: FastAPI): def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
@@ -214,8 +149,6 @@ def add_ip_block_middleware(app: FastAPI):
) )
return await call_next(request) return await call_next(request)
app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI): def add_first_install_middleware(app: FastAPI):
@app.middleware("http") @app.middleware("http")
@@ -228,5 +161,3 @@ def add_first_install_middleware(app: FastAPI):
): ):
return RedirectResponse("/first_install") return RedirectResponse("/first_install")
return await call_next(request) return await call_next(request)
app.middleware("http")(first_install_middleware)
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, Optional
from pydantic import BaseModel from pydantic import BaseModel
@@ -212,7 +212,7 @@ class Node(ABC):
pass pass
@abstractmethod @abstractmethod
async def get_channels(self) -> List[NodeChannel]: async def get_channels(self) -> list[NodeChannel]:
pass pass
@abstractmethod @abstractmethod
+26 -23
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException from fastapi import HTTPException
@@ -44,11 +44,12 @@ def catch_rpc_errors(f):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
try: try:
return await f(*args, **kwargs) return await f(*args, **kwargs)
except RpcError as e: except RpcError as exc:
if e.error["code"] == -32602: msg = exc.error["message"]
raise HTTPException(status_code=400, detail=e.error["message"]) if exc.error["code"] == -32602:
raise HTTPException(status_code=400, detail=msg) from exc
else: else:
raise HTTPException(status_code=500, detail=e.error["message"]) raise HTTPException(status_code=500, detail=msg) from exc
return wrapper return wrapper
@@ -66,9 +67,11 @@ class CoreLightningNode(Node):
# https://docs.corelightning.org/reference/lightning-connect # https://docs.corelightning.org/reference/lightning-connect
try: try:
await self.ln_rpc("connect", uri) await self.ln_rpc("connect", uri)
except RpcError as e: except RpcError as exc:
if e.error["code"] == 400: if exc.error["code"] == 400:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"]) raise HTTPException(
HTTPStatus.BAD_REQUEST, detail=exc.error["message"]
) from exc
else: else:
raise raise
@@ -76,12 +79,12 @@ class CoreLightningNode(Node):
async def disconnect_peer(self, peer_id: str): async def disconnect_peer(self, peer_id: str):
try: try:
await self.ln_rpc("disconnect", peer_id) await self.ln_rpc("disconnect", peer_id)
except RpcError as e: except RpcError as exc:
if e.error["code"] == -1: if exc.error["code"] == -1:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
detail=e.error["message"], detail=exc.error["message"],
) ) from exc
else: else:
raise raise
@@ -105,14 +108,14 @@ class CoreLightningNode(Node):
funding_txid=result["txid"], funding_txid=result["txid"],
output_index=result["outnum"], output_index=result["outnum"],
) )
except RpcError as e: except RpcError as exc:
message = e.error["message"] message = exc.error["message"]
if "amount: should be a satoshi amount" in message: if "amount: should be a satoshi amount" in message:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
detail="The amount is not a valid satoshi amount.", detail="The amount is not a valid satoshi amount.",
) ) from exc
if "Unknown peer" in message: if "Unknown peer" in message:
raise HTTPException( raise HTTPException(
@@ -121,7 +124,7 @@ class CoreLightningNode(Node):
"We where able to connect to the peer but CLN " "We where able to connect to the peer but CLN "
"can't find it when opening a channel." "can't find it when opening a channel."
), ),
) ) from exc
if "Owning subdaemon openingd died" in message: if "Owning subdaemon openingd died" in message:
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719 # https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
@@ -131,14 +134,14 @@ class CoreLightningNode(Node):
"Likely the peer didn't like our channel opening " "Likely the peer didn't like our channel opening "
"proposal and disconnected from us." "proposal and disconnected from us."
), ),
) ) from exc
if ( if (
"Number of pending channels exceed maximum" in message "Number of pending channels exceed maximum" in message
or "exceeds maximum chan size of 10 BTC" in message or "exceeds maximum chan size of 10 BTC" in message
or "Could not afford" in message or "Could not afford" in message
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) from exc
raise raise
@catch_rpc_errors @catch_rpc_errors
@@ -152,13 +155,13 @@ class CoreLightningNode(Node):
raise HTTPException(status_code=400, detail="Short id required") raise HTTPException(status_code=400, detail="Short id required")
try: try:
await self.ln_rpc("close", short_id) await self.ln_rpc("close", short_id)
except RpcError as e: except RpcError as exc:
message = e.error["message"] message = exc.error["message"]
if ( if (
"Short channel ID not active:" in message "Short channel ID not active:" in message
or "Short channel ID not found" in message or "Short channel ID not found" in message
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) from exc
else: else:
raise raise
@@ -168,7 +171,7 @@ class CoreLightningNode(Node):
return info["id"] return info["id"]
@catch_rpc_errors @catch_rpc_errors
async def get_peer_ids(self) -> List[str]: async def get_peer_ids(self) -> list[str]:
peers = await self.ln_rpc("listpeers") peers = await self.ln_rpc("listpeers")
return [p["id"] for p in peers["peers"] if p["connected"]] return [p["id"] for p in peers["peers"] if p["connected"]]
@@ -194,7 +197,7 @@ class CoreLightningNode(Node):
return NodePeerInfo(id=node["nodeid"]) return NodePeerInfo(id=node["nodeid"])
@catch_rpc_errors @catch_rpc_errors
async def get_channels(self) -> List[NodeChannel]: async def get_channels(self) -> list[NodeChannel]:
funds = await self.ln_rpc("listfunds") funds = await self.ln_rpc("listfunds")
nodes = await self.ln_rpc("listnodes") nodes = await self.ln_rpc("listnodes")
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]} nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
+13 -11
View File
@@ -4,7 +4,7 @@ import asyncio
import base64 import base64
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException from fastapi import HTTPException
from httpx import HTTPStatusError from httpx import HTTPStatusError
@@ -60,11 +60,13 @@ class LndRestNode(Node):
) )
try: try:
response.raise_for_status() response.raise_for_status()
except HTTPStatusError as e: except HTTPStatusError as exc:
json = e.response.json() json = exc.response.json()
if json: if json:
error = json.get("error") or json error = json.get("error") or json
raise HTTPException(e.response.status_code, detail=error.get("message")) raise HTTPException(
exc.response.status_code, detail=error.get("message")
) from exc
return response.json() return response.json()
def get(self, path: str, **kwargs): def get(self, path: str, **kwargs):
@@ -81,8 +83,8 @@ class LndRestNode(Node):
async def connect_peer(self, uri: str): async def connect_peer(self, uri: str):
try: try:
pubkey, host = uri.split("@") pubkey, host = uri.split("@")
except ValueError: except ValueError as exc:
raise HTTPException(400, detail="Invalid peer URI") raise HTTPException(400, detail="Invalid peer URI") from exc
await self.request( await self.request(
"POST", "POST",
"/v1/peers", "/v1/peers",
@@ -96,11 +98,11 @@ class LndRestNode(Node):
async def disconnect_peer(self, peer_id: str): async def disconnect_peer(self, peer_id: str):
try: try:
await self.request("DELETE", "/v1/peers/" + peer_id) await self.request("DELETE", "/v1/peers/" + peer_id)
except HTTPException as e: except HTTPException as exc:
if "unable to disconnect" in e.detail: if "unable to disconnect" in exc.detail:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, detail="Peer is not connected" HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
) ) from exc
raise raise
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo: async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
@@ -174,9 +176,9 @@ class LndRestNode(Node):
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required" status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
) )
asyncio.create_task(self._close_channel(point, force)) asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006
async def get_channels(self) -> List[NodeChannel]: async def get_channels(self) -> list[NodeChannel]:
normal, pending, closed = await asyncio.gather( normal, pending, closed = await asyncio.gather(
self.get("/v1/channels"), self.get("/v1/channels"),
self.get("/v1/channels/pending"), self.get("/v1/channels/pending"),
+1
View File
@@ -0,0 +1 @@
# Marker file for PEP 561
+6 -19
View File
@@ -16,7 +16,7 @@ from lnbits.settings import set_cli_settings, settings
} }
) )
@click.option("--port", default=settings.port, help="Port to listen on") @click.option("--port", default=settings.port, help="Port to listen on")
@click.option("--host", default=settings.host, help="Host to run LNBits on") @click.option("--host", default=settings.host, help="Host to run LNbits on")
@click.option( @click.option(
"--forwarded-allow-ips", "--forwarded-allow-ips",
default=settings.forwarded_allow_ips, default=settings.forwarded_allow_ips,
@@ -24,14 +24,16 @@ from lnbits.settings import set_cli_settings, settings
) )
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") @click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") @click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
@click.pass_context @click.option(
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
)
def main( def main(
ctx,
port: int, port: int,
host: str, host: str,
forwarded_allow_ips: str, forwarded_allow_ips: str,
ssl_keyfile: str, ssl_keyfile: str,
ssl_certfile: str, ssl_certfile: str,
reload: bool,
): ):
"""Launched with `poetry run lnbits` at root level""" """Launched with `poetry run lnbits` at root level"""
@@ -46,21 +48,6 @@ def main(
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips) set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
# this beautiful beast parses all command line arguments and
# passes them to the uvicorn server
d = {}
for a in ctx.args:
item = a.split("=")
if len(item) > 1: # argument like --key=value
print(a, item)
d[item[0].strip("--").replace("-", "_")] = (
int(item[1]) # need to convert to int if it's a number
if item[1].isdigit()
else item[1]
)
else:
d[a.strip("--")] = True # argument like --key
while True: while True:
config = uvicorn.Config( config = uvicorn.Config(
"lnbits.__main__:app", "lnbits.__main__:app",
@@ -70,7 +57,7 @@ def main(
forwarded_allow_ips=forwarded_allow_ips, forwarded_allow_ips=forwarded_allow_ips,
ssl_keyfile=ssl_keyfile, ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile, ssl_certfile=ssl_certfile,
**d reload=reload or False,
) )
server = uvicorn.Server(config=config) server = uvicorn.Server(config=config)
+216 -44
View File
@@ -9,7 +9,7 @@ from hashlib import sha256
from os import path from os import path
from sqlite3 import Row from sqlite3 import Row
from time import time from time import time
from typing import Any, List, Optional from typing import Any, Optional
import httpx import httpx
from loguru import logger from loguru import logger
@@ -36,8 +36,8 @@ class LNbitsSettings(BaseModel):
class UsersSettings(LNbitsSettings): class UsersSettings(LNbitsSettings):
lnbits_admin_users: List[str] = Field(default=[]) lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: List[str] = Field(default=[]) lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True) lnbits_allow_new_accounts: bool = Field(default=True)
@property @property
@@ -46,9 +46,10 @@ class UsersSettings(LNbitsSettings):
class ExtensionsSettings(LNbitsSettings): class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: List[str] = Field(default=[]) lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_manifests: List[str] = Field( lnbits_extensions_manifests: list[str] = Field(
default=[ default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json" "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
] ]
@@ -56,36 +57,150 @@ class ExtensionsSettings(LNbitsSettings):
class ExtensionsInstallSettings(LNbitsSettings): class ExtensionsInstallSettings(LNbitsSettings):
lnbits_extensions_default_install: List[str] = Field(default=[]) lnbits_extensions_default_install: list[str] = Field(default=[])
# required due to GitHUb rate-limit # required due to GitHUb rate-limit
lnbits_ext_github_token: str = Field(default="") lnbits_ext_github_token: str = Field(default="")
class RedirectPath(BaseModel):
ext_id: str
from_path: str
redirect_to_path: str
header_filters: dict = {}
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
return self.redirect_matches(
other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
for other in others:
if self.in_conflict(other):
return other
return None
def new_path_from(self, req_path: str) -> str:
from_path = self.from_path.split("/")
redirect_to = self.redirect_to_path.split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
return "/" + "/".join(elements)
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
return self._has_common_path(path) and self._has_headers(req_headers)
def _has_common_path(self, req_path: str) -> bool:
if len(self.from_path) > len(req_path):
return False
redirect_path_elements = self.from_path.split("/")
req_path_elements = req_path.split("/")
sub_path = req_path_elements[: len(redirect_path_elements)]
return self.from_path == "/".join(sub_path)
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
for h in self.header_filters:
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
return False
return True
def _has_header(
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
) -> bool:
for h in req_headers:
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
return True
return False
class InstalledExtensionsSettings(LNbitsSettings): class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated # installed extensions that have been deactivated
lnbits_deactivated_extensions: List[str] = Field(default=[]) lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects # upgraded extensions that require API redirects
lnbits_upgraded_extensions: List[str] = Field(default=[]) lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform # list of redirects that extensions want to perform
lnbits_extensions_redirects: List[Any] = Field(default=[]) lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]: # list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[])
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]:
headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next( return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")), (
r
for r in self.lnbits_extensions_redirects
if r.redirect_matches(path, headers)
),
None, None,
) )
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]: def activate_extension_paths(
path = settings.extension_upgrade_path(ext_id) self,
return path.split("/")[0] if path else None ext_id: str,
upgrade_hash: Optional[str] = None,
ext_redirects: Optional[list[dict]] = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects)
self.lnbits_all_extensions_ids.add(ext_id)
def deactivate_extension_paths(self, ext_id: str):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
]
existing_redirects = {
r.ext_id
for r in self.lnbits_extensions_redirects
if r.find_in_conflict(ext_redirect_paths)
}
assert len(existing_redirects) == 0, (
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths
def _remove_extension_redirects(self, ext_id: str):
self.lnbits_extensions_redirects = [
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
]
class ThemesSettings(LNbitsSettings): class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits") lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet") lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
lnbits_site_description: str = Field(default=None) lnbits_site_description: Optional[str] = Field(
default="The world's most powerful suite of bitcoin tools."
)
lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet") lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_theme_options: List[str] = Field( lnbits_custom_badge: Optional[str] = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
default=[ default=[
"classic", "classic",
"freedom", "freedom",
@@ -96,13 +211,13 @@ class ThemesSettings(LNbitsSettings):
"cyber", "cyber",
] ]
) )
lnbits_custom_logo: str = Field(default=None) lnbits_custom_logo: Optional[str] = Field(default=None)
lnbits_ad_space_title: str = Field(default="Supported by") lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field( lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png" default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: List[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: Optional[str] = Field(default=None) lnbits_default_accounting_currency: Optional[str] = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png") lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
@@ -114,7 +229,7 @@ class OpsSettings(LNbitsSettings):
lnbits_service_fee: float = Field(default=0) lnbits_service_fee: float = Field(default=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True) lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0) lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: str = Field(default=None) lnbits_service_fee_wallet: Optional[str] = Field(default=None)
lnbits_hide_api: bool = Field(default=False) lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats") lnbits_denomination: str = Field(default="sats")
@@ -122,8 +237,8 @@ class OpsSettings(LNbitsSettings):
class SecuritySettings(LNbitsSettings): class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200") lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_unit: str = Field(default="minute") lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: List[str] = Field(default=[]) lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: List[str] = Field(default=[]) lnbits_blocked_ips: list[str] = Field(default=[])
lnbits_notifications: bool = Field(default=False) lnbits_notifications: bool = Field(default=False)
lnbits_killswitch: bool = Field(default=False) lnbits_killswitch: bool = Field(default=False)
lnbits_killswitch_interval: int = Field(default=60) lnbits_killswitch_interval: int = Field(default=60)
@@ -152,7 +267,7 @@ class FakeWalletFundingSource(LNbitsSettings):
class LNbitsFundingSource(LNbitsSettings): class LNbitsFundingSource(LNbitsSettings):
lnbits_endpoint: str = Field(default="https://legend.lnbits.com") lnbits_endpoint: str = Field(default="https://demo.lnbits.com")
lnbits_key: Optional[str] = Field(default=None) lnbits_key: Optional[str] = Field(default=None)
lnbits_admin_key: Optional[str] = Field(default=None) lnbits_admin_key: Optional[str] = Field(default=None)
lnbits_invoice_key: Optional[str] = Field(default=None) lnbits_invoice_key: Optional[str] = Field(default=None)
@@ -164,6 +279,7 @@ class ClicheFundingSource(LNbitsSettings):
class CoreLightningFundingSource(LNbitsSettings): class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: Optional[str] = Field(default=None) corelightning_rpc: Optional[str] = Field(default=None)
corelightning_pay_command: str = Field(default="pay")
clightning_rpc: Optional[str] = Field(default=None) clightning_rpc: Optional[str] = Field(default=None)
@@ -208,11 +324,22 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None) lnpay_admin_key: Optional[str] = Field(default=None)
class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings): class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/") zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None) zbd_api_key: Optional[str] = Field(default=None)
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: Optional[str] = Field(default="http://localhost:9740/")
phoenixd_api_password: Optional[str] = Field(default=None)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/") alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
alby_access_token: Optional[str] = Field(default=None) alby_access_token: Optional[str] = Field(default=None)
@@ -237,6 +364,25 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None) lntips_invoice_key: Optional[str] = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600) lightning_invoice_expiry: int = Field(default=3600)
@@ -251,18 +397,23 @@ class FundingSourcesSettings(
LndRestFundingSource, LndRestFundingSource,
LndGrpcFundingSource, LndGrpcFundingSource,
LnPayFundingSource, LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource, AlbyFundingSource,
BoltzFundingSource,
ZBDFundingSource, ZBDFundingSource,
PhoenixdFundingSource,
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
LnTipsFundingSource, LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
): ):
lnbits_backend_wallet_class: str = Field(default="VoidWallet") lnbits_backend_wallet_class: str = Field(default="VoidWallet")
class WebPushSettings(LNbitsSettings): class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str = Field(default=None) lnbits_webpush_pubkey: Optional[str] = Field(default=None)
lnbits_webpush_privkey: str = Field(default=None) lnbits_webpush_privkey: Optional[str] = Field(default=None)
class NodeUISettings(LNbitsSettings): class NodeUISettings(LNbitsSettings):
@@ -286,7 +437,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings): class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600) auth_token_expire_minutes: int = Field(default=525600)
auth_all_methods = [a.value for a in AuthMethods] auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: List[str] = Field( auth_allowed_methods: list[str] = Field(
default=[ default=[
AuthMethods.user_id_only.value, AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value, AuthMethods.username_and_password.value,
@@ -377,6 +528,7 @@ class EnvSettings(LNbitsSettings):
log_retention: str = Field(default="3 months") log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time()) server_startup_time: int = Field(default=time())
cleanup_wallets_days: int = Field(default=90) cleanup_wallets_days: int = Field(default=90)
funding_source_max_retries: int = Field(default=4)
@property @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
@@ -395,21 +547,26 @@ class PersistenceSettings(LNbitsSettings):
class SuperUserSettings(LNbitsSettings): class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: List[str] = Field( lnbits_allowed_funding_sources: list[str] = Field(
default=[ default=[
"VoidWallet",
"FakeWallet",
"CoreLightningWallet",
"CoreLightningRestWallet",
"LndRestWallet",
"EclairWallet",
"LndWallet",
"LnTipsWallet",
"LNPayWallet",
"AlbyWallet", "AlbyWallet",
"ZBDWallet", "BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
"FakeWallet",
"LNPayWallet",
"LNbitsWallet", "LNbitsWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
"OpenNodeWallet", "OpenNodeWallet",
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
"NWCWallet",
] ]
) )
@@ -422,6 +579,12 @@ class TransientSettings(InstalledExtensionsSettings):
# - are cleared on server restart # - are cleared on server restart
first_install: bool = Field(default=False) first_install: bool = Field(default=False)
# Indicates that the server should continue to run.
# When set to false it indicates that the shutdown procedure is ongoing.
# If false no new tasks, threads, etc should be started.
# Long running while loops should use this flag instead of `while True:`
lnbits_running: bool = Field(default=True)
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
@@ -451,7 +614,7 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod @classmethod
def from_row(cls, row: Row) -> "Settings": def from_row(cls, row: Row) -> Settings:
data = dict(row) data = dict(row)
return cls(**data) return cls(**data)
@@ -461,7 +624,7 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
case_sensitive = False case_sensitive = False
json_loads = list_parse_fallback json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str): def is_user_allowed(self, user_id: str) -> bool:
return ( return (
len(self.lnbits_allowed_users) == 0 len(self.lnbits_allowed_users) == 0
or user_id in self.lnbits_allowed_users or user_id in self.lnbits_allowed_users
@@ -469,6 +632,15 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user or 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
def is_admin_extension(self, ext_id: str) -> bool:
return ext_id in self.lnbits_admin_extensions
def is_extension_id(self, ext_id: str) -> bool:
return ext_id in self.lnbits_all_extensions_ids
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):
super_user: str super_user: str
@@ -476,7 +648,7 @@ class SuperSettings(EditableSettings):
class AdminSettings(EditableSettings): class AdminSettings(EditableSettings):
is_super_user: bool is_super_user: bool
lnbits_allowed_funding_sources: Optional[List[str]] lnbits_allowed_funding_sources: Optional[list[str]]
def set_cli_settings(**kwargs): def set_cli_settings(**kwargs):
@@ -505,7 +677,7 @@ def send_admin_user_to_saas():
except Exception as e: except Exception as e:
logger.error( logger.error(
"error sending super_user to saas:" "error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {str(e)}" f" {settings.lnbits_saas_callback}. Error: {e!s}"
) )
@@ -531,10 +703,10 @@ if not settings.lnbits_admin_ui:
logger.debug(f"{key}: {value}") logger.debug(f"{key}: {value}")
def get_wallet_class(): def get_funding_source():
""" """
Backwards compatibility Backwards compatibility
""" """
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_funding_source
return get_wallet_class() return get_funding_source()
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
+9
View File
@@ -554,3 +554,12 @@ video {
.whitespace-pre-line { .whitespace-pre-line {
white-space: pre-line; white-space: pre-line;
} }
.q-carousel__slide {
background-size: contain;
background-repeat: no-repeat;
}
.q-dialog__inner--minimized {
padding: 12px;
}
+15 -2
View File
@@ -9,6 +9,8 @@ window.localisation.br = {
transactions: 'Transações', transactions: 'Transações',
dashboard: 'Painel de Controle', dashboard: 'Painel de Controle',
node: 'Nó', node: 'Nó',
export_users: 'Exportar Usuários',
no_users: 'Nenhum usuário encontrado',
total_capacity: 'Capacidade Total', total_capacity: 'Capacidade Total',
avg_channel_size: 'Tamanho médio do canal', avg_channel_size: 'Tamanho médio do canal',
biggest_channel_size: 'Maior Tamanho de Canal', biggest_channel_size: 'Maior Tamanho de Canal',
@@ -34,6 +36,8 @@ window.localisation.br = {
'Apagar todas as configurações e redefinir para os padrões.', 'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup do banco de dados', download_backup: 'Fazer backup do banco de dados',
name_your_wallet: 'Nomeie sua carteira %{name}', name_your_wallet: 'Nomeie sua carteira %{name}',
wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *', paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description: lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.', 'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -99,6 +103,7 @@ window.localisation.br = {
'Este é um código QR de retirada do LNURL para sugar tudo desta carteira. Não compartilhe com ninguém. É compatível com balanceCheck e balanceNotify para que sua carteira possa continuar retirando os fundos continuamente daqui após a primeira retirada.', 'Este é um código QR de retirada do LNURL para sugar tudo desta carteira. Não compartilhe com ninguém. É compatível com balanceCheck e balanceNotify para que sua carteira possa continuar retirando os fundos continuamente daqui após a primeira retirada.',
i_understand: 'Eu entendo', i_understand: 'Eu entendo',
copy_wallet_url: 'Copiar URL da carteira', copy_wallet_url: 'Copiar URL da carteira',
disclaimer_dialog_title: 'Importante!',
disclaimer_dialog: disclaimer_dialog:
'Funcionalidade de login a ser lançada em uma atualização futura, por enquanto, certifique-se de marcar esta página para acesso futuro à sua carteira! Este serviço está em BETA, e não nos responsabilizamos por pessoas que perderem o acesso aos fundos.', 'Funcionalidade de login a ser lançada em uma atualização futura, por enquanto, certifique-se de marcar esta página para acesso futuro à sua carteira! Este serviço está em BETA, e não nos responsabilizamos por pessoas que perderem o acesso aos fundos.',
no_transactions: 'Ainda não foram feitas transações', no_transactions: 'Ainda não foram feitas transações',
@@ -164,7 +169,7 @@ window.localisation.br = {
'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.', 'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
killswitch_interval: 'Intervalo do Killswitch', killswitch_interval: 'Intervalo do Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).', 'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
enable_watchdog: 'Ativar Watchdog', enable_watchdog: 'Ativar Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.', 'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.',
@@ -241,5 +246,13 @@ window.localisation.br = {
logout: 'Sair', logout: 'Sair',
look_and_feel: 'Aparência', look_and_feel: 'Aparência',
language: 'Idioma', language: 'Idioma',
color_scheme: 'Esquema de Cores' color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
extension_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',
retry_install: 'Repetir Instalação',
new_payment: 'Efetuar Novo Pagamento',
hide_empty_wallets: 'Ocultar carteiras vazias'
} }
+15 -2
View File
@@ -9,6 +9,8 @@ window.localisation.cn = {
transactions: '交易记录', transactions: '交易记录',
dashboard: '控制面板', dashboard: '控制面板',
node: '节点', node: '节点',
export_users: '导出用户',
no_users: '未找到用户',
total_capacity: '总容量', total_capacity: '总容量',
avg_channel_size: '平均频道大小', avg_channel_size: '平均频道大小',
biggest_channel_size: '最大通道大小', biggest_channel_size: '最大通道大小',
@@ -33,6 +35,8 @@ window.localisation.cn = {
reset_defaults_tooltip: '删除所有设置并重置为默认设置', reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份', download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字', name_your_wallet: '给你的 %{name}钱包起个名字',
wallet_topup_ok:
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
paste_invoice_label: '粘贴发票,付款请求或lnurl*', paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description: lnbits_description:
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。', 'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
@@ -95,6 +99,7 @@ window.localisation.cn = {
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金', '这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
i_understand: '我明白', i_understand: '我明白',
copy_wallet_url: '复制钱包URL', copy_wallet_url: '复制钱包URL',
disclaimer_dialog_title: '重要!',
disclaimer_dialog: disclaimer_dialog:
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。', '登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
no_transactions: '尚未进行任何交易', no_transactions: '尚未进行任何交易',
@@ -154,7 +159,7 @@ window.localisation.cn = {
'如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。', '如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。',
killswitch_interval: 'Killswitch 间隔', killswitch_interval: 'Killswitch 间隔',
killswitch_interval_desc: killswitch_interval_desc:
'后台任务应该多久检查一次来自状态源的LNBits断路信号(以分钟为单位)。', '后台任务应该多久检查一次来自状态源的LNbits断路信号(以分钟为单位)。',
enable_watchdog: '启用看门狗', enable_watchdog: '启用看门狗',
enable_watchdog_desc: enable_watchdog_desc:
'如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。', '如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。',
@@ -229,5 +234,13 @@ window.localisation.cn = {
logout: '注销', logout: '注销',
look_and_feel: '外观和感觉', look_and_feel: '外观和感觉',
language: '语言', language: '语言',
color_scheme: '配色方案' color_scheme: '配色方案',
extension_cost: '此版本需要支付最低 %{cost} sats。',
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
release_details_error: '无法获取发布详情。',
pay_from_wallet: '从钱包支付',
show_qr: '显示QR码',
retry_install: '重试安装',
new_payment: '创建新支付',
hide_empty_wallets: '隐藏空钱包'
} }
+15 -2
View File
@@ -9,6 +9,8 @@ window.localisation.cs = {
transactions: 'Transakce', transactions: 'Transakce',
dashboard: 'Přehled', dashboard: 'Přehled',
node: 'Uzel', node: 'Uzel',
export_users: 'Exportovat uživatele',
no_users: 'Nebyli nalezeni žádní uživatelé',
total_capacity: 'Celková kapacita', total_capacity: 'Celková kapacita',
avg_channel_size: 'Průmerná velikost kanálu', avg_channel_size: 'Průmerná velikost kanálu',
biggest_channel_size: 'Největší velikost kanálu', biggest_channel_size: 'Největší velikost kanálu',
@@ -33,6 +35,8 @@ window.localisation.cs = {
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.', reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
download_backup: 'Stáhnout zálohu databáze', download_backup: 'Stáhnout zálohu databáze',
name_your_wallet: 'Pojmenujte svou %{name} peněženku', name_your_wallet: 'Pojmenujte svou %{name} peněženku',
wallet_topup_ok:
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *', paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description: lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.', 'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
@@ -99,6 +103,7 @@ window.localisation.cs = {
'Toto je LNURL-withdraw QR kód pro vyčerpání všeho z této peněženky. Nesdílejte s nikým. Je kompatibilní s balanceCheck a balanceNotify, takže vaše peněženka může kontinuálně čerpat prostředky odsud po prvním výběru.', 'Toto je LNURL-withdraw QR kód pro vyčerpání všeho z této peněženky. Nesdílejte s nikým. Je kompatibilní s balanceCheck a balanceNotify, takže vaše peněženka může kontinuálně čerpat prostředky odsud po prvním výběru.',
i_understand: 'Rozumím', i_understand: 'Rozumím',
copy_wallet_url: 'Kopírovat URL peněženky', copy_wallet_url: 'Kopírovat URL peněženky',
disclaimer_dialog_title: 'Důležité!',
disclaimer_dialog: disclaimer_dialog:
'Funkcionalita přihlášení bude vydána v budoucí aktualizaci, zatím si ujistěte, že jste si tuto stránku uložili do záložek pro budoucí přístup k vaší peněžence! Tato služba je v BETA verzi a nepřebíráme žádnou zodpovědnost za ztrátu přístupu k prostředkům.', 'Funkcionalita přihlášení bude vydána v budoucí aktualizaci, zatím si ujistěte, že jste si tuto stránku uložili do záložek pro budoucí přístup k vaší peněžence! Tato služba je v BETA verzi a nepřebíráme žádnou zodpovědnost za ztrátu přístupu k prostředkům.',
no_transactions: 'Zatím žádné transakce', no_transactions: 'Zatím žádné transakce',
@@ -161,7 +166,7 @@ window.localisation.cs = {
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.', 'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.',
killswitch_interval: 'Interval Killswitch', killswitch_interval: 'Interval Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNBits ze zdroje stavu (v minutách).', 'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNbits ze zdroje stavu (v minutách).',
enable_watchdog: 'Povolit Watchdog', enable_watchdog: 'Povolit Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.', 'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.',
@@ -238,5 +243,13 @@ window.localisation.cs = {
logout: 'Odhlásit se', logout: 'Odhlásit se',
look_and_feel: 'Vzhled a chování', look_and_feel: 'Vzhled a chování',
language: 'Jazyk', language: 'Jazyk',
color_scheme: 'Barevné schéma' color_scheme: 'Barevné schéma',
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
extension_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',
retry_install: 'Zkusit znovu nainstalovat',
new_payment: 'Vytvořit novou platbu',
hide_empty_wallets: 'Skrýt prázdné peněženky'
} }
+16 -2
View File
@@ -9,6 +9,8 @@ window.localisation.de = {
transactions: 'Transaktionen', transactions: 'Transaktionen',
dashboard: 'Armaturenbrett', dashboard: 'Armaturenbrett',
node: 'Knoten', node: 'Knoten',
export_users: 'Benutzer exportieren',
no_users: 'Keine Benutzer gefunden',
total_capacity: 'Gesamtkapazität', total_capacity: 'Gesamtkapazität',
avg_channel_size: 'Durchschn. Kanalgröße', avg_channel_size: 'Durchschn. Kanalgröße',
biggest_channel_size: 'Größte Kanalgröße', biggest_channel_size: 'Größte Kanalgröße',
@@ -34,6 +36,8 @@ window.localisation.de = {
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.', 'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
download_backup: 'Datenbank-Backup herunterladen', download_backup: 'Datenbank-Backup herunterladen',
name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen', name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen',
wallet_topup_ok:
'Erfolg beim Erstellen von virtuellen Mitteln (%{amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
paste_invoice_label: paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *', 'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description: lnbits_description:
@@ -102,6 +106,7 @@ window.localisation.de = {
'LNURL-withdraw QR-Code, der das Abziehen aller Geldmittel aus dieser Wallet erlaubt. Teile ihn mit niemandem! Kompatibel mit balanceCheck und balanceNotify, so dass dein Wallet die Sats nach dem ersten Abzug kontinuierlich von hier abziehen kann.', 'LNURL-withdraw QR-Code, der das Abziehen aller Geldmittel aus dieser Wallet erlaubt. Teile ihn mit niemandem! Kompatibel mit balanceCheck und balanceNotify, so dass dein Wallet die Sats nach dem ersten Abzug kontinuierlich von hier abziehen kann.',
i_understand: 'Ich verstehe', i_understand: 'Ich verstehe',
copy_wallet_url: 'Wallet-URL kopieren', copy_wallet_url: 'Wallet-URL kopieren',
disclaimer_dialog_title: 'Wichtig!',
disclaimer_dialog: disclaimer_dialog:
'Login-Funktionalität wird in einem zukünftigen Update veröffentlicht. Bis dahin ist die Speicherung der Wallet-URL als Lesezeichen absolut notwendig, um Zugriff auf die Wallet zu erhalten! Dieser Service ist in BETA und wir übernehmen keine Verantwortung für Verluste durch verlorene Zugriffe.', 'Login-Funktionalität wird in einem zukünftigen Update veröffentlicht. Bis dahin ist die Speicherung der Wallet-URL als Lesezeichen absolut notwendig, um Zugriff auf die Wallet zu erhalten! Dieser Service ist in BETA und wir übernehmen keine Verantwortung für Verluste durch verlorene Zugriffe.',
no_transactions: 'Keine Transaktionen', no_transactions: 'Keine Transaktionen',
@@ -166,7 +171,7 @@ window.localisation.de = {
'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.', 'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.',
killswitch_interval: 'Intervall für den Notausschalter', killswitch_interval: 'Intervall für den Notausschalter',
killswitch_interval_desc: killswitch_interval_desc:
'Wie oft die Hintergrundaufgabe nach dem LNBits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).', 'Wie oft die Hintergrundaufgabe nach dem LNbits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
enable_watchdog: 'Aktiviere Watchdog', enable_watchdog: 'Aktiviere Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.', 'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.',
@@ -245,5 +250,14 @@ window.localisation.de = {
logout: 'Abmelden', logout: 'Abmelden',
look_and_feel: 'Aussehen und Verhalten', look_and_feel: 'Aussehen und Verhalten',
language: 'Sprache', language: 'Sprache',
color_scheme: 'Farbschema' color_scheme: 'Farbschema',
extension_cost:
'Diese Version erfordert eine Zahlung von mindestens %{cost} Sats.',
extension_paid_sats: 'Sie haben bereits %{paid_sats} Sats bezahlt.',
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
show_qr: 'QR anzeigen',
retry_install: 'Installieren erneut versuchen',
new_payment: 'Neue Zahlung vornehmen',
hide_empty_wallets: 'Leere Geldbörsen verbergen'
} }
+23 -7
View File
@@ -9,6 +9,8 @@ window.localisation.en = {
transactions: 'Transactions', transactions: 'Transactions',
dashboard: 'Dashboard', dashboard: 'Dashboard',
node: 'Node', node: 'Node',
export_users: 'Export Users',
no_users: 'No users found',
total_capacity: 'Total Capacity', total_capacity: 'Total Capacity',
avg_channel_size: 'Avg. Channel Size', avg_channel_size: 'Avg. Channel Size',
biggest_channel_size: 'Biggest Channel Size', biggest_channel_size: 'Biggest Channel Size',
@@ -33,6 +35,8 @@ window.localisation.en = {
reset_defaults_tooltip: 'Delete all settings and reset to defaults.', reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup', download_backup: 'Download database backup',
name_your_wallet: 'Name your %{name} wallet', name_your_wallet: 'Name your %{name} wallet',
wallet_topup_ok:
'Success creating virtual funds (%{amount} sats). Payments depend on actual funds on funding source.',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *', paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
lnbits_description: lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.', 'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
@@ -98,11 +102,8 @@ window.localisation.en = {
i_understand: 'I understand', i_understand: 'I understand',
copy_wallet_url: 'Copy wallet URL', copy_wallet_url: 'Copy wallet URL',
disclaimer_dialog_title: 'Important!', disclaimer_dialog_title: 'Important!',
disclaimer_dialog: `You *must* save your login credentials to be able to access your wallet again.If you lose them, you will lose access to your wallet and funds. disclaimer_dialog:
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
Find your login credentials on your account settings page.
This service is in BETA. LNbits holds no responsibility for loss of access to funds.`,
no_transactions: 'No transactions made yet', no_transactions: 'No transactions made yet',
manage: 'Manage', manage: 'Manage',
extensions: 'Extensions', extensions: 'Extensions',
@@ -117,6 +118,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
installed: 'Installed', installed: 'Installed',
@@ -143,6 +145,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_sats: 'Amount (sats)',
tag: 'Tag', tag: 'Tag',
unit: 'Unit', unit: 'Unit',
description: 'Description', description: 'Description',
@@ -162,7 +165,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.', 'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
killswitch_interval: 'Killswitch Interval', killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc: killswitch_interval_desc:
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).', 'How often the background task should check for the LNbits killswitch signal from the status source (in minutes).',
enable_watchdog: 'Enable Watchdog', enable_watchdog: 'Enable Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.', 'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
@@ -238,13 +241,26 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
back: 'Back', back: 'Back',
logout: 'Logout', logout: 'Logout',
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
language: 'Language', language: 'Language',
color_scheme: 'Color Scheme', color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.', extension_cost: 'This release requires a payment of minimum %{cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.', extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *',
show_qr: 'Show QR', show_qr: 'Show QR',
retry_install: 'Retry Install', retry_install: 'Retry Install',
new_payment: 'Make New Payment' new_payment: 'Make New Payment',
update_payment: 'Update Payment',
already_paid_question: 'Have you already paid?',
sell: 'Sell',
sell_require: 'Ask payment to enable extension',
sell_info:
'The %{name} extension requires a payment of minimum %{amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck',
contributors: 'Contributors',
license: 'License'
} }
+15 -2
View File
@@ -9,6 +9,8 @@ window.localisation.es = {
transactions: 'Transacciones', transactions: 'Transacciones',
dashboard: 'Tablero de instrumentos', dashboard: 'Tablero de instrumentos',
node: 'Nodo', node: 'Nodo',
export_users: 'Exportar Usuarios',
no_users: 'No se encontraron usuarios',
total_capacity: 'Capacidad Total', total_capacity: 'Capacidad Total',
avg_channel_size: 'Tamaño Medio del Canal', avg_channel_size: 'Tamaño Medio del Canal',
biggest_channel_size: 'Tamaño del Canal Más Grande', biggest_channel_size: 'Tamaño del Canal Más Grande',
@@ -34,6 +36,8 @@ window.localisation.es = {
'Borrar todas las configuraciones y restablecer a los valores predeterminados.', 'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
download_backup: 'Descargar copia de seguridad de la base de datos', download_backup: 'Descargar copia de seguridad de la base de datos',
name_your_wallet: 'Nombre de su billetera %{name}', name_your_wallet: 'Nombre de su billetera %{name}',
wallet_topup_ok:
'Éxito creando fondos virtuales (%{amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
paste_invoice_label: 'Pegue la factura aquí', paste_invoice_label: 'Pegue la factura aquí',
lnbits_description: lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.', 'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
@@ -99,6 +103,7 @@ window.localisation.es = {
'Este es un código QR LNURL-withdraw para drenar todos los fondos de esta billetera. No lo comparta con nadie. Es compatible con balanceCheck y balanceNotify, por lo que su billetera puede continuar drenando los fondos de aquí después del primer drenaje.', 'Este es un código QR LNURL-withdraw para drenar todos los fondos de esta billetera. No lo comparta con nadie. Es compatible con balanceCheck y balanceNotify, por lo que su billetera puede continuar drenando los fondos de aquí después del primer drenaje.',
i_understand: 'Lo entiendo', i_understand: 'Lo entiendo',
copy_wallet_url: 'Copiar URL de billetera', copy_wallet_url: 'Copiar URL de billetera',
disclaimer_dialog_title: '¡Importante!',
disclaimer_dialog: disclaimer_dialog:
'La funcionalidad de inicio de sesión se lanzará en una actualización futura, por ahora, asegúrese de guardar esta página como marcador para acceder a su billetera en el futuro. Este servicio está en BETA y no asumimos ninguna responsabilidad por personas que pierdan el acceso a sus fondos.', 'La funcionalidad de inicio de sesión se lanzará en una actualización futura, por ahora, asegúrese de guardar esta página como marcador para acceder a su billetera en el futuro. Este servicio está en BETA y no asumimos ninguna responsabilidad por personas que pierdan el acceso a sus fondos.',
no_transactions: 'No hay transacciones todavía', no_transactions: 'No hay transacciones todavía',
@@ -164,7 +169,7 @@ window.localisation.es = {
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.', 'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.',
killswitch_interval: 'Intervalo de Killswitch', killswitch_interval: 'Intervalo de Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNBits desde la fuente de estado (en minutos).', 'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNbits desde la fuente de estado (en minutos).',
enable_watchdog: 'Activar Watchdog', enable_watchdog: 'Activar Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.', 'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.',
@@ -243,5 +248,13 @@ window.localisation.es = {
logout: 'Cerrar sesión', logout: 'Cerrar sesión',
look_and_feel: 'Apariencia', look_and_feel: 'Apariencia',
language: 'Idioma', language: 'Idioma',
color_scheme: 'Esquema de colores' color_scheme: 'Esquema de colores',
extension_cost: 'Esta versión requiere un pago mínimo de %{cost} sats.',
extension_paid_sats: 'Ya has pagado %{paid_sats} sats.',
release_details_error: 'No se pueden obtener los detalles de la versión.',
pay_from_wallet: 'Pagar desde la billetera',
show_qr: 'Mostrar QR',
retry_install: 'Reintentar Instalación',
new_payment: 'Realizar nuevo pago',
hide_empty_wallets: 'Ocultar billeteras vacías'
} }
+16 -3
View File
@@ -9,6 +9,8 @@ window.localisation.fi = {
transactions: 'Tapahtumat', transactions: 'Tapahtumat',
dashboard: 'Ohjauspaneeli', dashboard: 'Ohjauspaneeli',
node: 'Solmu', node: 'Solmu',
export_users: 'Vie käyttäjät',
no_users: 'Käyttäjiä ei löytynyt',
total_capacity: 'Kokonaiskapasiteetti', total_capacity: 'Kokonaiskapasiteetti',
avg_channel_size: 'Keskimääräisen kanavan kapasiteetti', avg_channel_size: 'Keskimääräisen kanavan kapasiteetti',
biggest_channel_size: 'Suurimman kanavan kapasiteetti', biggest_channel_size: 'Suurimman kanavan kapasiteetti',
@@ -34,6 +36,8 @@ window.localisation.fi = {
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.', 'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio', download_backup: 'Lataa tietokannan varmuuskopio',
name_your_wallet: 'Anna %{name}-lompakollesi nimi', name_your_wallet: 'Anna %{name}-lompakollesi nimi',
wallet_topup_ok:
'Virtuaalisten varojen luominen onnistui (%{amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
paste_invoice_label: paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *', 'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description: lnbits_description:
@@ -62,9 +66,9 @@ window.localisation.fi = {
service_fee_max: service_fee_max:
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)', 'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
service_fee_tooltip: service_fee_tooltip:
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.', 'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä', toggle_darkmode: 'Tumma näkymä',
toggle_reactions: 'Käytä tapahtuma efektejä', payment_reactions: 'Maksureaktiot',
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit', view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
api_docs: 'API-dokumentaatio', api_docs: 'API-dokumentaatio',
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio', api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
@@ -102,6 +106,7 @@ window.localisation.fi = {
'Tämä LNURL-withdraw -tyyppinen QR-koodi on tarkoitettu kaikkien varojen imurointiin lompakosta. ÄLÄ JAA SITÄ KENELLEKÄÄN! Se on balanceCheck- ja balanceNotify-toimintojen kanssa yhteensopiva, joten sitä voi käyttää lompakon tyhjentämiseen ensimmäisen käytön jälleen jatkuvasti.', 'Tämä LNURL-withdraw -tyyppinen QR-koodi on tarkoitettu kaikkien varojen imurointiin lompakosta. ÄLÄ JAA SITÄ KENELLEKÄÄN! Se on balanceCheck- ja balanceNotify-toimintojen kanssa yhteensopiva, joten sitä voi käyttää lompakon tyhjentämiseen ensimmäisen käytön jälleen jatkuvasti.',
i_understand: 'Vakuutan ymmärtäväni', i_understand: 'Vakuutan ymmärtäväni',
copy_wallet_url: 'Kopioi lompakon URL', copy_wallet_url: 'Kopioi lompakon URL',
disclaimer_dialog_title: 'Tärkeää!',
disclaimer_dialog: disclaimer_dialog:
'Muistathan tallettaa kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossakin kirjautumaan lompakkoosi! Tutustu myös Tilin asetukset -sivuun. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.', 'Muistathan tallettaa kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossakin kirjautumaan lompakkoosi! Tutustu myös Tilin asetukset -sivuun. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa', no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
@@ -241,5 +246,13 @@ window.localisation.fi = {
logout: 'Poistu', logout: 'Poistu',
look_and_feel: 'Kieli ja värit', look_and_feel: 'Kieli ja värit',
language: 'Kieli', language: 'Kieli',
color_scheme: 'Väriteema' color_scheme: 'Väriteema',
extension_cost: 'Tämä julkaisu edellyttää vähintään %{cost} satsin maksua.',
extension_paid_sats: 'Olet jo maksanut %{paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta',
show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen',
new_payment: 'Tee uusi maksu',
hide_empty_wallets: 'Piilota tyhjät lompakot'
} }
+16 -2
View File
@@ -9,6 +9,8 @@ window.localisation.fr = {
transactions: 'Transactions', transactions: 'Transactions',
dashboard: 'Tableau de bord', dashboard: 'Tableau de bord',
node: 'Noeud', node: 'Noeud',
export_users: 'Exporter les utilisateurs',
no_users: 'Aucun utilisateur trouvé',
total_capacity: 'Capacité totale', total_capacity: 'Capacité totale',
avg_channel_size: 'Taille moyenne du canal', avg_channel_size: 'Taille moyenne du canal',
biggest_channel_size: 'Taille de canal maximale', biggest_channel_size: 'Taille de canal maximale',
@@ -36,6 +38,8 @@ window.localisation.fr = {
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.', 'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
download_backup: 'Télécharger la sauvegarde de la base de données', download_backup: 'Télécharger la sauvegarde de la base de données',
name_your_wallet: 'Nommez votre portefeuille %{name}', name_your_wallet: 'Nommez votre portefeuille %{name}',
wallet_topup_ok:
'Succès de la création de fonds virtuels (%{amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
paste_invoice_label: paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *', 'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description: lnbits_description:
@@ -103,6 +107,7 @@ window.localisation.fr = {
"Il s'agit d'un code QR LNURL-withdraw pour tout aspirer de ce portefeuille. Ne le partagez avec personne. Il est compatible avec balanceCheck et balanceNotify, de sorte que votre portefeuille peut continuer à retirer les fonds continuellement à partir d'ici après le premier retrait.", "Il s'agit d'un code QR LNURL-withdraw pour tout aspirer de ce portefeuille. Ne le partagez avec personne. Il est compatible avec balanceCheck et balanceNotify, de sorte que votre portefeuille peut continuer à retirer les fonds continuellement à partir d'ici après le premier retrait.",
i_understand: "J'ai compris", i_understand: "J'ai compris",
copy_wallet_url: "Copier l'URL du portefeuille", copy_wallet_url: "Copier l'URL du portefeuille",
disclaimer_dialog_title: 'Important !',
disclaimer_dialog: disclaimer_dialog:
"La fonctionnalité de connexion sera publiée dans une future mise à jour, pour l'instant, assurez-vous de mettre cette page en favori pour accéder à votre portefeuille ultérieurement ! Ce service est en BETA, et nous ne sommes pas responsables des personnes qui perdent l'accès à leurs fonds.", "La fonctionnalité de connexion sera publiée dans une future mise à jour, pour l'instant, assurez-vous de mettre cette page en favori pour accéder à votre portefeuille ultérieurement ! Ce service est en BETA, et nous ne sommes pas responsables des personnes qui perdent l'accès à leurs fonds.",
no_transactions: 'Aucune transaction effectuée pour le moment', no_transactions: 'Aucune transaction effectuée pour le moment',
@@ -168,7 +173,7 @@ window.localisation.fr = {
'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.', 'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.',
killswitch_interval: 'Intervalle du Killswitch', killswitch_interval: 'Intervalle du Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNBits provenant de la source de statut (en minutes).", "À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNbits provenant de la source de statut (en minutes).",
enable_watchdog: 'Activer le Watchdog', enable_watchdog: 'Activer le Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.', 'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.',
@@ -247,5 +252,14 @@ window.localisation.fr = {
logout: 'Déconnexion', logout: 'Déconnexion',
look_and_feel: 'Apparence', look_and_feel: 'Apparence',
language: 'Langue', language: 'Langue',
color_scheme: 'Schéma de couleurs' color_scheme: 'Schéma de couleurs',
extension_cost:
'Cette version nécessite un paiement minimum de %{cost} sats.',
extension_paid_sats: 'Vous avez déjà payé %{paid_sats} sats.',
release_details_error: "Impossible d'obtenir les détails de la version.",
pay_from_wallet: 'Payer depuis le portefeuille',
show_qr: 'Afficher le QR',
retry_install: "Réessayer l'installation",
new_payment: 'Effectuer un nouveau paiement',
hide_empty_wallets: 'Masquer les portefeuilles vides'
} }
+16 -2
View File
@@ -9,6 +9,8 @@ window.localisation.it = {
transactions: 'Transazioni', transactions: 'Transazioni',
dashboard: 'Pannello di controllo', dashboard: 'Pannello di controllo',
node: 'Interruttore', node: 'Interruttore',
export_users: 'Esporta utenti',
no_users: 'Nessun utente trovato',
total_capacity: 'Capacità Totale', total_capacity: 'Capacità Totale',
avg_channel_size: 'Dimensione media del canale', avg_channel_size: 'Dimensione media del canale',
biggest_channel_size: 'Dimensione del canale più grande', biggest_channel_size: 'Dimensione del canale più grande',
@@ -34,6 +36,8 @@ window.localisation.it = {
'Cancella tutte le impostazioni e ripristina i valori predefiniti', 'Cancella tutte le impostazioni e ripristina i valori predefiniti',
download_backup: 'Scarica il backup del database', download_backup: 'Scarica il backup del database',
name_your_wallet: 'Dai un nome al tuo portafoglio %{name}', name_your_wallet: 'Dai un nome al tuo portafoglio %{name}',
wallet_topup_ok:
'Operazione riuscita nella creazione di fondi virtuali (%{amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
paste_invoice_label: paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *', 'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description: lnbits_description:
@@ -100,6 +104,7 @@ window.localisation.it = {
'Questo è un codice QR <code>LNURL-withdraw</code> per prelevare tutti i fondi da questo portafoglio. Non condividerlo con nessuno. È compatibile con <code>balanceCheck</code> e <code>balanceNotify</code>, di conseguenza il vostro portafoglio può continuare a prelevare continuamente i fondi da qui dopo il primo prelievo', 'Questo è un codice QR <code>LNURL-withdraw</code> per prelevare tutti i fondi da questo portafoglio. Non condividerlo con nessuno. È compatibile con <code>balanceCheck</code> e <code>balanceNotify</code>, di conseguenza il vostro portafoglio può continuare a prelevare continuamente i fondi da qui dopo il primo prelievo',
i_understand: 'Ho capito', i_understand: 'Ho capito',
copy_wallet_url: 'Copia URL portafoglio', copy_wallet_url: 'Copia URL portafoglio',
disclaimer_dialog_title: 'Importante!',
disclaimer_dialog: disclaimer_dialog:
"La funzionalità di login sarà rilasciata in un futuro aggiornamento; per ora, assicuratevi di salvare tra i preferiti questa pagina per accedere nuovamente in futuro a questo portafoglio! Questo servizio è in fase BETA e non ci assumiamo alcuna responsabilità per la perdita all'accesso dei fondi", "La funzionalità di login sarà rilasciata in un futuro aggiornamento; per ora, assicuratevi di salvare tra i preferiti questa pagina per accedere nuovamente in futuro a questo portafoglio! Questo servizio è in fase BETA e non ci assumiamo alcuna responsabilità per la perdita all'accesso dei fondi",
no_transactions: 'Nessuna transazione effettuata', no_transactions: 'Nessuna transazione effettuata',
@@ -164,7 +169,7 @@ window.localisation.it = {
'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.', 'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.',
killswitch_interval: 'Intervallo Killswitch', killswitch_interval: 'Intervallo Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNBits dalla fonte di stato (in minuti).', 'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNbits dalla fonte di stato (in minuti).',
enable_watchdog: 'Attiva Watchdog', enable_watchdog: 'Attiva Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.', 'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.',
@@ -244,5 +249,14 @@ window.localisation.it = {
logout: 'Esci', logout: 'Esci',
look_and_feel: 'Aspetto e Comportamento', look_and_feel: 'Aspetto e Comportamento',
language: 'Lingua', language: 'Lingua',
color_scheme: 'Schema dei colori' color_scheme: 'Schema dei colori',
extension_cost:
'Questa versione richiede un pagamento minimo di %{cost} satoshi.',
extension_paid_sats: 'Hai già pagato %{paid_sats} sats.',
release_details_error: 'Impossibile ottenere i dettagli della versione.',
pay_from_wallet: 'Paga dal Portafoglio',
show_qr: 'Mostra QR',
retry_install: 'Riprova Installazione',
new_payment: 'Effettua Nuovo Pagamento',
hide_empty_wallets: 'Nascondi portafogli vuoti'
} }
+15 -2
View File
@@ -9,6 +9,8 @@ window.localisation.jp = {
transactions: 'トランザクション', transactions: 'トランザクション',
dashboard: 'ダッシュボード', dashboard: 'ダッシュボード',
node: 'ノード', node: 'ノード',
export_users: 'ユーザーのエクスポート',
no_users: 'ユーザーが見つかりません',
total_capacity: '合計容量', total_capacity: '合計容量',
avg_channel_size: '平均チャンネルサイズ', avg_channel_size: '平均チャンネルサイズ',
biggest_channel_size: '最大チャネルサイズ', biggest_channel_size: '最大チャネルサイズ',
@@ -33,6 +35,8 @@ window.localisation.jp = {
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。', reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
download_backup: 'データベースのバックアップをダウンロードする', download_backup: 'データベースのバックアップをダウンロードする',
name_your_wallet: 'あなたのウォレットの名前 %{name}', name_your_wallet: 'あなたのウォレットの名前 %{name}',
wallet_topup_ok:
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。',
paste_invoice_label: '請求書を貼り付けてください', paste_invoice_label: '請求書を貼り付けてください',
lnbits_description: lnbits_description:
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。', '簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
@@ -97,6 +101,7 @@ window.localisation.jp = {
drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します', drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します',
i_understand: '理解した', i_understand: '理解した',
copy_wallet_url: 'ウォレットURLをコピー', copy_wallet_url: 'ウォレットURLをコピー',
disclaimer_dialog_title: '重要!',
disclaimer_dialog: disclaimer_dialog:
'ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。ウォレットを削除する前に、ウォレットをエクスポートしてください。', 'ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。ウォレットを削除する前に、ウォレットをエクスポートしてください。',
no_transactions: 'トランザクションはありません', no_transactions: 'トランザクションはありません',
@@ -161,7 +166,7 @@ window.localisation.jp = {
'有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。', '有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。',
killswitch_interval: 'キルスイッチ間隔', killswitch_interval: 'キルスイッチ間隔',
killswitch_interval_desc: killswitch_interval_desc:
'バックグラウンドタスクがステータスソースからLNBitsキルスイッチ信号を確認する頻度(分単位)。', 'バックグラウンドタスクがステータスソースからLNbitsキルスイッチ信号を確認する頻度(分単位)。',
enable_watchdog: 'ウォッチドッグを有効にする', enable_watchdog: 'ウォッチドッグを有効にする',
enable_watchdog_desc: enable_watchdog_desc:
'有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。', '有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。',
@@ -240,5 +245,13 @@ window.localisation.jp = {
logout: 'ログアウト', logout: 'ログアウト',
look_and_feel: 'ルック・アンド・フィール', look_and_feel: 'ルック・アンド・フィール',
language: '言語', language: '言語',
color_scheme: 'カラースキーム' color_scheme: 'カラースキーム',
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。',
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。',
release_details_error: 'リリースの詳細を取得できません。',
pay_from_wallet: 'ウォレットから支払う',
show_qr: 'QRを表示',
retry_install: '再試行インストール',
new_payment: '新しい支払いを作成する',
hide_empty_wallets: '空のウォレットを非表示にする'
} }
+16 -3
View File
@@ -9,6 +9,8 @@ window.localisation.kr = {
transactions: '거래 내역', transactions: '거래 내역',
dashboard: '현황판', dashboard: '현황판',
node: '노드', node: '노드',
export_users: '사용자 내보내기',
no_users: '사용자가 없습니다',
total_capacity: '총 용량', total_capacity: '총 용량',
avg_channel_size: '평균 채널 용량', avg_channel_size: '평균 채널 용량',
biggest_channel_size: '가장 큰 채널 용량', biggest_channel_size: '가장 큰 채널 용량',
@@ -34,9 +36,11 @@ window.localisation.kr = {
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.', '설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
download_backup: '데이터베이스 백업 다운로드', download_backup: '데이터베이스 백업 다운로드',
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요', name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
wallet_topup_ok:
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *', paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description: lnbits_description:
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.', '설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기', export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
export_to_phone_desc: export_to_phone_desc:
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.', '이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
@@ -58,7 +62,7 @@ window.localisation.kr = {
service_fee: '서비스 수수료: 거래액의 %{amount} %', service_fee: '서비스 수수료: 거래액의 %{amount} %',
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)', service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
service_fee_tooltip: service_fee_tooltip:
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료', '지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환', toggle_darkmode: '다크 모드 전환',
payment_reactions: '결제 반응', payment_reactions: '결제 반응',
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다', view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
@@ -98,6 +102,7 @@ window.localisation.kr = {
'이는 선택된 지갑으로부터 모든 자금을 인출하는 LNURL-withdraw QR 코드입니다. 그 누구와도 공유하지 마세요. balanceCheck 및 balanceNotify 기능과 호환되며, 당신의 지갑은 첫 출금 이후로도 계속 자금을 끌어당기고 있을 수 있습니다.', '이는 선택된 지갑으로부터 모든 자금을 인출하는 LNURL-withdraw QR 코드입니다. 그 누구와도 공유하지 마세요. balanceCheck 및 balanceNotify 기능과 호환되며, 당신의 지갑은 첫 출금 이후로도 계속 자금을 끌어당기고 있을 수 있습니다.',
i_understand: '이해하였습니다', i_understand: '이해하였습니다',
copy_wallet_url: '지갑 URL 복사하기', copy_wallet_url: '지갑 URL 복사하기',
disclaimer_dialog_title: '중요!',
disclaimer_dialog: disclaimer_dialog:
'로그인 기능은 향후 업데이트를 통해 지원될 계획이지만, 현재로써는 이 페이지에 향후 다시 접속하기 위해 북마크 설정하는 것을 잊지 마세요! 이 서비스는 아직 BETA 과정에 있고, LNbits 개발자들은 자금 손실에 대해 전혀 책임을 지지 않습니다.', '로그인 기능은 향후 업데이트를 통해 지원될 계획이지만, 현재로써는 이 페이지에 향후 다시 접속하기 위해 북마크 설정하는 것을 잊지 마세요! 이 서비스는 아직 BETA 과정에 있고, LNbits 개발자들은 자금 손실에 대해 전혀 책임을 지지 않습니다.',
no_transactions: '아직 아무런 거래도 이루어지지 않았습니다', no_transactions: '아직 아무런 거래도 이루어지지 않았습니다',
@@ -237,5 +242,13 @@ window.localisation.kr = {
logout: '로그아웃', logout: '로그아웃',
look_and_feel: '외관과 느낌', look_and_feel: '외관과 느낌',
language: '언어', language: '언어',
color_scheme: '색상 구성' color_scheme: '색상 구성',
extension_cost: '이 버전은 최소 %{cost} sats의 지불이 필요합니다.',
extension_paid_sats: '당신은 이미 %{paid_sats} sats를 지불했습니다.',
release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.',
pay_from_wallet: '지갑에서 결제하다',
show_qr: 'QR 보기',
retry_install: '다시 설치하세요',
new_payment: '새로운 결제하기',
hide_empty_wallets: '빈 지갑 숨기기'
} }

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