Compare commits

...
164 Commits
Author SHA1 Message Date
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
dni ⚡andGitHub e9e69d9d17 chore: update lnbits to 0.12.5 (#2401)
update!!!
2024-04-11 14:20:25 +02:00
dni ⚡andGitHub 2c5ccc0b1b fix: check_fundingsource retries (#2400)
should fix SaaS crashing when retrying
2024-04-11 14:19:09 +02:00
Vlad StanandGitHub ea58b51619 test: add tests for alby (#2390)
* test: initial commit

* chore: code format

* fix: comment out bad `status.pending` (to be fixed in core)

* fix: 404 tests

* test: extract first `create_invoice` test

* chore: reminder

* add: error test

* chore: experiment

* feat: adapt parsing

* refactor: data structure

* fix: some tests

* fix: make response uniform

* fix: test data

* chore: clean-up

* fix: uniform responses

* fix: user agent

* fix: user agent

* fix: user-agent again

* test: add `with error` test

* feat: customize test name

* fix: better exception handling for `status`

* fix: add `try-catch` for `raise_for_status`

* test: with no mocks

* chore: clean-up generalized tests

* chore: code format

* chore: code format

* chore: remove extracted tests

* test: add `create_invoice`: error test

* add: test for `create_invoice` with http 404

* test: extract `test_pay_invoice_ok`

* test: extract `test_pay_invoice_error_response`

* test: extract `test_pay_invoice_http_404`

* test: add "missing data"

* test: add `bad-json`

* test: add `no mocks` for `create_invoice`

* test: add `no mocks` for `pay_invoice`

* test: add `bad json` tests

* chore: re-order tests

* test: add `missing data` test for `pay_imvoice`

* chore: re-order tests

* test: add `success` test for `get_invoice_status `

* feat: update test structure

* test: new status

* test: add more test

* chore: code clean-up

* test: add success test for `get_payment_status `

* test: add `pending` tests for `check_payment_status`

* chore: remove extracted tests

* test: add more tests

* test: add `no mocks` test

* fix: funding source loading

* refactor: start to extract data model

* chore: final clean-up

* chore: rename file

* test: add tests for alby

* refactor: `KeyError` handling

* chore: log error

* chore: skip the negative fee test

* fix: error message fetching
2024-04-08 13:26:00 +03:00
Vlad StanandGitHub bfda0b62da test: add unit tests for wallets (funding sources) (#2363)
* test: initial commit

* feat: allow external label for `create_invoice` (useful for testing)

* chore: code format

* fix: ignore temp coverage files

* feat: add properties to the Status classes for a better readability

* fix: add extra validation for data

* fix: comment out bad `status.pending` (to be fixed in core)

* fix: 404 tests

* test: first draft of generic rest wallet tests

* test: migrate two more tests

* feat: add response type

* feat: test exceptions

* test: extract first `create_invoice` test

* chore: reminder

* add: error test

* chore: code format

* chore: experiment

* feat: adapt parsing

* refactor: data structure

* fix: some tests

* refactor: extract methods

* fix: make response uniform

* fix: test data

* chore: clean-up

* fix: uniform responses

* fix: user agent

* fix: user agent

* fix: user-agent again

* test: add `with error` test

* feat: customize test name

* fix: better exception handling for `status`

* fix: add `try-catch` for `raise_for_status`

* test: with no mocks

* chore: clean-up generalized tests

* chore: code format

* chore: code format

* chore: remove extracted tests

* test: add `create_invoice`: error test

* add: test for `create_invoice` with http 404

* test: extract `test_pay_invoice_ok`

* test: extract `test_pay_invoice_error_response`

* test: extract `test_pay_invoice_http_404`

* test: add "missing data"

* test: add `bad-json`

* test: add `no mocks` for `create_invoice`

* test: add `no mocks` for `pay_invoice`

* test: add `bad json` tests

* chore: re-order tests

* fix: response type

* test: add `missing data` test for `pay_imvoice`

* chore: re-order tests

* test: add `success` test for `get_invoice_status `

* feat: update test structure

* test: new status

* test: add more test

* fix: error handling

* chore: code clean-up

* test: add success test for `get_payment_status `

* test: add `pending` tests for `check_payment_status`

* chore: remove extracted tests

* test: add more tests

* test: add `no mocks` test

* fix: funding source loading

* refactor: extract `rest_wallet_fixtures_from_json` function

* chore: update comment

* feat: cover `cleanup` call also

* chore: code format

* refactor: start to extract data model

* refactor: extract mock class

* fix: typings

* refactor: improve typings

* chore: add some documentation

* chore: final clean-up

* chore: rename file

* chore: `poetry add --dev pytest_httpserver` (after rebase)
2024-04-08 12:18:21 +03:00
dni ⚡andGitHub b0a8e0d942 doc: add hint to configure reverse proxy in .env.template (#2393)
closes #2134

suggestion from issue
2024-04-05 15:55:26 +03:00
dni ⚡andPavol Rusnak 2161b2511c bundle 2024-04-05 11:00:45 +02:00
dni ⚡andPavol Rusnak 1b2a9c54b6 feat: active state for manage menu items
closes #2190
2024-04-05 11:00:45 +02:00
dni ⚡andGitHub 209e2c5bff test: make nice pytest reports on github (#2376)
* test: make nice pytest reports on github
utilizing this action: https://github.com/pavelzw/pytest-action/
2024-04-05 08:35:51 +02:00
820882db28 refactor: use new fastapi lifespan instead of startup/shutdown events (#2294)
* refactor: use new fastapi lifespan instead of events
recommended use: https://fastapi.tiangolo.com/advanced/events/?h=lifespan
threw warnings in pytest
* make startup and shutdown functions
* nix: add override for asgi-lifespan

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-04-05 07:05:26 +02:00
dni ⚡andPavol Rusnak d64239f1ad fix: remove trailing slash from admin.js
fixing 307 requests making a request to `/settings/`
2024-04-04 08:55:18 +02:00
dni ⚡andPavol Rusnak 2066fff994 docs: improve installation for none ubuntu
closes #1878
2024-04-04 00:23:21 +02:00
dni ⚡andGitHub e0b7d2f739 chore: adhere to ruff's C (#2379) 2024-04-03 17:56:05 +02:00
dni ⚡andPavol Rusnak cd66b7d70c chore: fix pycryptodomex security issue
https://github.com/lnbits/lnbits/security/dependabot/41
2024-04-03 17:43:28 +02:00
dni ⚡andPavol Rusnak dc74f5816f chore: update black, security warning
https://github.com/lnbits/lnbits/security/dependabot/52
2024-04-03 11:39:40 +02:00
dni ⚡andPavol Rusnak c67fcf0e45 chore: adhere to ruff's A
dont use pythjons builts in functions name as variable
2024-04-02 17:11:06 +02:00
redistayandGitHub 073b117528 chore: fix some typos for docs (#2384)
Signed-off-by: redistay <wujunjing@outlook.com>
2024-04-02 16:08:31 +02:00
dni ⚡andGitHub ef6bc4c3ae chore: typo in function name nofiy_upgrade (#2383)
small typo in ext manager
2024-04-02 08:38:12 +02:00
ArcandGitHub 468121a453 docs: improves readme (#2367)
* Improves readme
* Added light/dark logo
2024-03-29 15:29:37 +01:00
dni ⚡andGitHub 4679d321de test: remove warnings for TemplateResponse (#2368)
* test: remove warnings for `TemplateResponse`
removes warnings
```
tests/core/views/test_generic.py::test_core_views_generic
tests/core/views/test_generic.py::test_get_wallet_with_user_and_wallet
tests/core/views/test_generic.py::test_get_extensions
tests/core/views/test_public_api.py::test_core_views_generic
  /home/dni/.cache/pypoetry/virtualenvs/lnbits-XeqO4Z-j-py3.10/lib/python3.10/site-packages/starlette/templating.py:178: DeprecationWarning: The `name` is not the first para
meter anymore. The first parameter should be the `Request` instance.
  Replace `TemplateResponse(name, {"request": request})` by `TemplateResponse(request, name)`.
    warnings.warn(
```
2024-03-29 12:22:14 +01:00
dni ⚡andGitHub c9af15b320 test: run jmeter test from lnbits-extensions repo (#2374)
* test: run jmeter test from `lnbits-extensions` repo
moves the integration tests for extensions into the `lnbits-extensions` repo.
more info here: https://github.com/lnbits/lnbits-extensions/pull/283
2024-03-29 11:18:49 +01:00
Vlad StanandGitHub 9b5bb1b07a [test] add jmeter tests to install and enable all vetted extensions (#2371)
* test: initial commit
* feat: allow excluding of certain releases
* test: activate and enable
* fix: title indexes
* chore: remove disabled element
* test: skip `cashu` too many DB errors
* chore: improve logs
* chore: test logs
* chore: logs
* fix: condition
* fix: condition harder
* test: disable some listeners
* fix: remove hanging element
* fix: multiple reports with: `-m 1, --max-count, stop reading a file after 1 matching line`
* chore: test that github actions still detect failures
* Revert "chore: test that github actions still detect failures"

This reverts commit a6769c5fd1.
2024-03-28 10:22:39 +01:00
aaa6573272 simplify description in i18n (#2356)
* simplify description in i18n
so we do not have to always edit description when a new funding source is added
* chore: make bundle

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-03-28 09:06:47 +01:00
dni ⚡andGitHub 741ecac78b feat: improve on api structure, add openapi tags (#2295)
this logically groups api endpoints and gioves them specific openapi tags. which makes them nice on the `/docs` endpoint and makes the `api.py` more approachable
* add wallets list endpoint
* remove trailing slashes from endpoints
* fixup topup url
* fix trailing slash on auth
* backwards compatibility
2024-03-28 08:59:28 +01:00
Tiago VasconcelosandPavol Rusnak 1dd096213e v-text missing 2024-03-27 17:41:08 +01:00
dni ⚡andGitHub ed44244291 bug: show extensions in frontend had wrong boolean (#2370)
bugfix
2024-03-27 12:00:50 +01:00
dni ⚡andGitHub 0b22c41d55 ci: run jmeter only if linting passes to save resources (#2366)
also uses `prepare` action to initialize python env
2024-03-26 20:27:12 +01:00
dni ⚡andGitHub 25c6ee40f7 bug: releasing docker image had invalid credentials (#2365)
* bug: releasing docker image had invalid credentials

closes #2345

also removed the duplicate dockerhub upload for latest. that was kinda dump (i introduced it), because you could also manually tag a special release which would than always push to latest tag aswell which makes no sense.
2024-03-26 20:26:31 +01:00
dni ⚡andGitHub cdc102af07 chore: update to node 20.x on workflows (#2364)
gets rid of deprecating warnings in actions
* checkout@v4
* cache@v4
* setup-python@v5
* setup-node@v4
* codecov-action@v4
* codecov pass token from ci
2024-03-26 14:18:58 +01:00
dni ⚡andGitHub b9f0af0e79 feat: complete use of LNBITS_EXTENSIONS_DEACTIVATE_ALL (#2341)
- add to admin ui
- move to `ExtensionsSettings`
- hide left navbar when disabled
- do not register extension routes on startup
- do not check for installed extensions on startup
- reafctor into register_all_ext_routes
2024-03-26 11:44:33 +01:00
Gonçalo ValérioandGitHub 86821f4606 bug: When "expiry" is null, do not display "expiry" in payment details (#2349)
* Do not display expiry in payment details, when expiry is null
2024-03-26 10:46:30 +01:00
dni ⚡andGitHub 0ef9c4db10 hotfix: initial currency conversion on balance (#2346)
* hotfix: initial currency conversion on balance
fiat amount not shown in balance on initial pageload, lets reintroduce the first fetch_balance to fix it.
maybe in the future balance call and wallet itself should give the current fiat value if the wallet uses fiat tracking
2024-03-26 10:41:36 +01:00
Vlad Stan c101b85054 chore: add extra log to check-payments command 2024-03-25 13:14:35 +02:00
Vlad StanandGitHub fe3b00292a feat: extra log (#2360) 2024-03-25 12:58:49 +02:00
Vlad StanandGitHub 10fe113099 feat: remove autofix (#2359) 2024-03-25 11:22:16 +01:00
Vlad Stan 1bf5f10f53 fix: exclude internal invoices from being settled 2024-03-25 11:35:21 +02:00
Vlad StanandGitHub e2bbcaabbd feat: add mark-payment-pending command (#2355)
* feat: add `mark-payment-pending` command; add `--auto-fix` to `check-payments`

* chore: code format

* chore: code clean-up
2024-03-24 23:46:52 +02:00
Vlad StanandGitHub 4265915201 feat: add commands delete-walle, delete-wallet-payment and verbose option, (#2354)
* feat: add `verbose` option

* feat: add `database_delete_wallet `

* feat: add `database_delete_wallet_payment`
2024-03-24 21:51:59 +02:00
Vlad StanandGitHub 7e3c511027 feat: add check_invalid_payments command (#2353)
* feat: add `check_invalid_payments` command

* fix: str options to int
2024-03-24 19:11:30 +02:00
ArcandGitHub 7783f34998 bug: wrong pending status (#2352) 2024-03-24 13:58:15 +01:00
dni ⚡ 7dcb2fcdd5 chore: release 0.12.4 2024-03-24 13:44:47 +01:00
dni ⚡andGitHub 033a57cad4 chore: update to 0.12.3 (#2344) 2024-03-22 13:03:00 +01:00
dni ⚡andGitHub 299228b7b5 feat: reduce initial requests on wallet page (#2335)
* feat: reduce initial request on wallet page
- refactor allowed_currencies into function to use in generic and api.
- remove currencies request in frontend move it to generic
- dont request balance on first payments fetch
2024-03-22 12:59:49 +01:00
dni ⚡andGitHub 5b022e2ef3 feat: make route hints configurable for lndrest (#2304)
* feat: make route hints configurable for lndrest
boolean if route hints for private channels will be included from `LndRest` inside bolt11 invoice.
2024-03-22 12:45:39 +01:00
Pavol RusnakandGitHub c8818f5774 remove CACHE_VERSION from service-worker.js (#2342)
use server_startup_time as the version
(the same trick we use for static files)
2024-03-22 12:33:42 +01:00
dni ⚡andGitHub 1398857688 refactor: move registering of tasks into app.py (#2290)
* refactor: move registering of tasks into `app.py`

let the app have the responsibility to create the tasks not a helper function inside tasks.py.
makes it way easier to follow on how those background tasks are ran.
2024-03-22 12:29:05 +01:00
Gonçalo ValérioandGitHub 2a1505bc0d bug: Fix regression. Admin-users and allowed-users can now be removed through the web interface (#2331)
* Fix regression. Admin-users and allowed-users can now be removed through the web interface
* remove raw usage from user tab on server admin
2024-03-22 07:26:25 +01:00
dni ⚡andGitHub 4bba6ecc82 hotfix: qrcode broke wallet.html (#2340)
introduced by 43a79744
2024-03-21 17:39:12 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
522c4df44c chore(deps): bump follow-redirects from 1.15.5 to 1.15.6 (#2326)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-21 16:43:48 +01:00
dni ⚡andGitHub c03b81d2ea refactor: tiny url to use require decorators and wallet.id (#2338)
- also use `wallet.wallet.id` as key instead of `wallet.wallet.inkey`
2024-03-21 13:32:55 +01:00
arbadacarbaandPavol Rusnak 43a797444a recover user for export-to-phone 2024-03-21 13:14:28 +01:00
dni ⚡andPavol Rusnak d0cb961b49 bug: wallet page fetchbalance ran twice
there is a listener on payments which executes fetchbalance automatically if payments change. line 822
2024-03-21 13:13:33 +01:00
ArcandGitHub d208e68885 feat: added home button on error page (#2330)
* Added redirect on error page if user id not present as users keep getting stuck
2024-03-21 12:24:38 +01:00
dni ⚡andGitHub 8dcb53aea0 chore: keychecker todo remove type ignores (#2337)
fixes a comment `TODO`
2024-03-21 11:36:01 +01:00
dni ⚡andGitHub 241b286e21 chore: update ruff and black in precommit config (#2336)
ran `poetry lock --no-update`
2024-03-21 11:05:01 +01:00
dni ⚡andGitHub 17839f6a25 feat: add database debug env var (#2333)
* feat: add database debug env var
makes it visible what db actions happen
2024-03-21 09:59:17 +01:00
dni ⚡andGitHub e4d3faefa0 feat: create_unique_task helper (#2292)
this is especially useful for extension.

with this an extension can only call `create_permanent_unique_task("my-ext", task)` and it will restarted when it exists and cancels when lnbits is stopped
2024-03-18 13:02:04 +00:00
116ca7f011 Remove all {% raw %} usage (#2318)
* Remove raw from templates 
* Remove v-text from q-tooltip
* extension buttons
* Move more to spans
* Reformatted

---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2024-03-15 11:10:52 +01:00
Vlad StanandGitHub d44339b018 fix: pending property for PaymentStatus (#2324)
* fix: pending property for PaymentStatus

* fix: invoice status

* fix: check pending status from the payment details

* refactor: make condition more explicit
2024-03-14 14:38:10 +02:00
Vlad StanandGitHub 352fd23c0b refactor: dev-friendly payment status name (#2322)
* refactor: dev-friendly payment status name
* refactor: change `PaymentStatus(True, ...)` to `PaymentSuccessStatus(...)`
2024-03-13 16:17:33 +01:00
Vlad StanandGitHub 65b8868c36 fix: enforce order of payments (#2313)
* fix: enforce order of payments

* fix: do not return wallet by key if the wallet is deleted
2024-03-12 15:31:40 +02:00
dni ⚡andGitHub 54dec171f9 fix: raise failed request to not run mark_webhook_sent (#2289)
* fix: webhook sent raise a failed request

is not raised even failed webhook would be marked is sent

* add warn log

* fix error

* improve exceptions

* fixup!

* ConnectError is already captured by RequestError

https://www.python-httpx.org/exceptions/

* log exception
2024-03-12 15:12:24 +02:00
Vlad StanandGitHub 5b4398911a fix: check if key present in settings before updating (#2306) 2024-03-12 13:03:11 +00:00
4c0bd132b1 bug: cln paymentstate check on error (#2151)
* CLN: paymentstate check on error

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-03-12 13:56:36 +01:00
7ce4eddb0e feat: add group_by to fetch_page (#2140)
---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-03-12 13:55:38 +01:00
callebtcandGitHub 14519135d8 Fix: LNbits backend payent state (#2187) 2024-03-12 13:43:41 +01:00
dni ⚡andGitHub 60839481ad feat: add tool preimage generator (#2321)
`poetry run python tools/preimage.py`
2024-03-12 11:46:46 +01:00
dni ⚡andGitHub 3ef1941fc0 test: fix TestModel warning (#2317)
PytestCollectionWarning: cannot collect test class 'TestModel' because it has a __init__ constructor (from: tests/core/test_helpers_query.py)
2024-03-12 11:32:10 +01:00
dni ⚡andGitHub 16cb1a8026 fix: lnbits funding source (#2319)
fix a bug where payments are deleted
2024-03-12 11:28:42 +01:00
dni ⚡andGitHub e3b9bd6a70 fix: update pre-commit ruff (#2316)
wasnt working after latest ruff update
2024-03-12 08:47:13 +01:00
Vlad StanandGitHub a1ea04acf8 Jmeter take 3 (#2284)
* chore: add tests

* chore: add reference to jmter job

* chore: add jmeter job

* chore: touch tests

* chorE: disable other workflows

* chore: do not set `LNBITS_ADMIN_UI: true`

* chore: extra log file

* chore: rollback

* chore: testing stuff

* feat: top-up wallet

* chore: clean-up usr

* chore: clean-up user query param

* chore: remove hardcoded usr

* chore: clean-up

* chore: clean-up

* chore: different improvements

* chore: clean-up

* chore: remove hardcoded values

* refactor: extract `init-server` fragment

* chore: move fragment

* feat: use test frgment

* chore: clean-up hardcoded values

* chore: clean-up

* fix: get lnurl working

* chore: re-arange elements

* chore: re-arange elements

* fix: add `lnurlp, withdraw` extensions

* chore: rename file

* fix: login with user id

* refactor: extract init account

* fix: tips wallet

* fix: assertion

* refactor: use fragments; group loops

* chore: re-enable the other CI jobs

* fix: separate wallet for receive

* feat: check receive wallet balance

* feat: increase wait time
2024-03-08 16:40:23 +02:00
3e341a3555 update black to 24.2.0 and ruff to 0.3.0 (#2307)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-03-07 09:55:37 +00:00
Vlad StanandGitHub cb5c9b03bf Fix login logout on safari (#2309)
* fix: relax security level for public cookies

* fix: corner case for logout
2024-03-07 09:22:42 +00:00
dni ⚡andGitHub 884a1b9d6f test: lint tests and tools aswell (#2296)
* test: lint `tests` and `tools` aswell

more linting :)

* fix linting issues in tests and tools

* fixup!

* how is this working?
2024-02-27 13:30:52 +00:00
Vlad StanandGitHub e8aa498683 feat: add get endpoint for user wallets (#2299)
* feat: add get endpoint for user wallets

* feat: only expose relevant fields

* refactor: extract `BaseWallet`

* doc: add open api doc
2024-02-27 10:08:10 +02:00
Pavol Rusnak 1b7efd854a chore: make bundle 2024-02-23 16:11:27 -03:00
Pavol Rusnak 8b32c3dcb6 remove webhook_listener
following commit e419c74ebb
2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak 0821b28eac rename self.auth to headers 2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak 4c71d5ac42 format file 2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak a8e0b5a5ac use bolt11.decoder from lnbits instead of zbd remote 2024-02-23 16:11:27 -03:00
BitkarrotandPavol Rusnak 9b9014a042 Update zbd.py
set timeout on pay_invoice to 40
2024-02-23 16:11:27 -03:00
Pavol Rusnak 96b42b1784 add ZBD to homepage 2024-02-23 16:11:27 -03:00
BloggingBitcoinandPavol Rusnak 26d854ebe8 added ZBD logos 2024-02-23 16:11:27 -03:00
Pavol Rusnak ed36d01b40 fix typing issues 2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak 646556a457 add zbd to translation files 2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak d20a35eddc add zbd to settings
update api methods

add zbd as funding source to settings and js

fix statuses

cast str into int for status method

fix outbound payment hash issue

restore create_invoice

remove print stmts
2024-02-23 16:11:27 -03:00
bitkarrotandPavol Rusnak d69946db8a initial scaffolding for zbd wallet 2024-02-23 16:11:27 -03:00
dni ⚡andPavol Rusnak ddab4075d2 fix: raise proper exception in services pay_invoice
also values where checked twice
2024-02-23 00:39:00 -03:00
d6c8ad1d0d Paid extensions (#2229)
* fix: download archive file `async`

* feat: add `pay_link` property

* feat: basic install using internal wallet for payment

* fix: pop-up issues

* chore: refactor

* feat: detect paid extensions

* fix: payment check

* feat: small stuff

* feat: show external invoice

* fix: regression for extension install

* feat: store previos successful payments

* refactor: simplify, almost works

* chore: gugu gaga

* fix: pay and install

* fix: do not pay invoice on the back-end

* chore: code clean-up

* feat: basic websocker listener

* feat: use websocket to watch for invoice payment

* feat: remember hanging invoices

* refactor: extract `localStorage` methods

* chore: code format

* chore: code clean-up after test

* feat: remember previous payment_hashes

* chore: code format

* refactor: rename `ExtensionPaymentInfo` to `ReleasePaymentInfo`

* refactor: method rename

* fix: release version matters now

* chore: code format

* refactor: method rename

* refactor: extract method `_restore_payment_info`

* refactor: extract method

* chore: rollback `CACHE_VERSION`

* chore: code format

* feat: i18n

* chore: update bundle

* refactor: public method name

* chore: code format

* fix: websocket connection

* Update installation.md (#2259)

* Update installation.md (#2260)

* fix: try to fix `openapi` error

* chore: bundle

* chore:bundle

---------

Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-02-22 13:16:41 +00:00
dni ⚡andPavol Rusnak 54c6faa4b6 refactor: move mark_webhook_sent into crud.py
database functions should be in crud
2024-02-21 11:14:04 -03:00
dni ⚡andPavol Rusnak c88f05c5c0 refactor: tasks should be run with internal wrapper
listeners used did not use the helper function
2024-02-21 10:56:31 -03:00
dni ⚡andPavol Rusnak e419c74ebb refactor: remove unused webhook handler for wallets
it was only used in lnpay, opennode and alby which all had it disabled anayways with a pretty old TODO, so i guess the feature is disabled for a y ear now and nobody used it, so i just removed it
2024-02-21 10:55:57 -03:00
dni ⚡andGitHub b6dc66b070 chore: remove dead code send_push_promise (#2285)
dead code removal :)
2024-02-21 14:24:06 +02:00
dni ⚡andGitHub 7b4d9bf5db chore: update to 0.12.2 (#2282)
update!!!
2024-02-21 11:09:43 +01:00
Vlad StanandGitHub c51e7351e8 fix: extension stop background work (#2281)
* feat: add helper methods

* fix: do not try to stop background work on first install

* fix: first stop via function call then try REST API

* fix: `make check`

* fix: prepare for `{ext_id}_stop`
2024-02-21 12:08:37 +02:00
fe12eccc42 Updated new wallet warning modal text to clarify importance of backing up login creds (#2273)
* Updated new wallet warning modal text to clarify importance of backing up creds


Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-02-20 14:36:39 +02:00
dni ⚡andGitHub 17b7826753 feat: unique invoice listeners for extensions (#2250)
changes the `register_invoice_listener` to use a non unique key so you its not possible to have a listeners running twice and throw a warning when trying to.
2024-02-20 14:03:29 +02:00
14ae6d8b1a feat: add node url to api keys & docs (#2283)
* feat: add node url to api keys & docs

closes #2277

* add finnish to tools/i18n-ai-tool.py

* regenerate localization with i18n-ai-tool

* chore: make bundle

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-02-20 13:32:49 +02:00
dni ⚡andGitHub 7b559991c7 fix: docker image invoice listener were not stopping (#2278)
because it could not resolve localhost
2024-02-16 15:24:50 +02:00
dni ⚡andGitHub decd4cdf0a chore: remove listener dict (#2279) 2024-02-16 14:47:52 +02:00
dni ⚡andGitHub dc7a6551c5 chore: deprecate get_current_extension_name (#2280) 2024-02-16 13:48:50 +02:00
186 changed files with 14740 additions and 4960 deletions
+1
View File
@@ -9,6 +9,7 @@ lnbits/static/bundle.js
lnbits/static/bundle.css
*.md
!README.md
*.log
.env
+20 -3
View File
@@ -15,7 +15,7 @@ LNBITS_ADMIN_UI=false
# Change theme
LNBITS_SITE_TITLE="LNbits"
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
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# 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
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet"
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# 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
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"
# LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_ENDPOINT=https://demo.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
@@ -84,6 +87,14 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY
@@ -132,6 +143,8 @@ KEYCLOAK_DISCOVERY_URL=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
@@ -169,6 +182,9 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# 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
# 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
LNBITS_HIDE_API=false
@@ -222,6 +238,7 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
+1 -1
View File
@@ -1 +1 @@
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+5 -5
View File
@@ -10,7 +10,7 @@ inputs:
default: "1.7.0"
node-version:
description: "Node Version"
default: "18.x"
default: "20.x"
npm:
description: "use npm"
default: false
@@ -21,7 +21,7 @@ runs:
using: "composite"
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
@@ -38,7 +38,7 @@ runs:
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
@@ -50,11 +50,11 @@ runs:
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- uses: actions/cache@v3
- uses: actions/cache@v4
if: ${{ (inputs.npm == 'true') }}
name: Define a cache for the npm based on the dependencies lock file
with:
+50 -5
View File
@@ -12,7 +12,7 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml
tests:
test-api:
needs: [ lint ]
strategy:
matrix:
@@ -20,17 +20,48 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
migrations:
test-wallets:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9"]
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
make: test-migration
db-name: migration
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 }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
migration:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
uses: ./.github/workflows/migration.yml
with:
python-version: ${{ matrix.python-version }}
openapi:
needs: [ lint ]
@@ -46,5 +77,19 @@ jobs:
python-version: ["3.9"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jmeter:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 2
- run: git checkout HEAD^2
+5 -10
View File
@@ -11,6 +11,11 @@ on:
tag:
default: latest
type: string
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
jobs:
push_to_dockerhub:
@@ -46,13 +51,3 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push latest tag
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+53
View File
@@ -0,0 +1,53 @@
name: JMeter Extension Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.9"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
jmeter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
poetry run lnbits &
sleep 5
- name: clone lnbits-extensions, install jmeter and run tests
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
mkdir logs
mkdir reports
make install-jmeter
make start-mirror-server
make test
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: jmeter-extension-test-results
path: |
lnbits-extensions/reports/
lnbits-extensions/logs/
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
node-version: ["18.x"]
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/prepare
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: ${{ inputs.python-version }}
node-version: ${{ matrix.node-version }}
+37
View File
@@ -0,0 +1,37 @@
name: migration
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
make:
name: migration (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-latest"]
runs-on: ${{ matrix.os-version }}
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: migration
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- run: make test-migration
+29 -23
View File
@@ -3,8 +3,9 @@ name: regtest
on:
workflow_call:
inputs:
make:
default: test
custom-pytest:
description: "Custom pytest arguments"
required: true
type: string
python-version:
default: "3.9"
@@ -15,27 +16,21 @@ on:
backend-wallet-class:
required: true
type: string
secrets:
CODECOV_TOKEN:
required: true
jobs:
regtest:
runs-on: ${{ inputs.os-version }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Docker Buildx
- name: docker build
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/setup-buildx-action@v2
- name: Build and push
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/build-push-action@v4
with:
context: .
push: false
tags: lnbits/lnbits:latest
cache-from: type=registry,ref=lnbits/lnbits:latest
cache-to: type=inline
run: |
docker build -t lnbits/lnbits .
- name: Setup Regtest
run: |
@@ -49,11 +44,8 @@ jobs:
with:
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 Tests
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
@@ -72,10 +64,24 @@ jobs:
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
ECLAIR_PASS: lnbits
run: make test-real-wallet
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: ${{ inputs.custom-pytest }}
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
- name: docker lnbits logs
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: |
docker logs lnbits-lnbits-1
+14 -1
View File
@@ -10,10 +10,11 @@ permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -26,3 +27,15 @@ jobs:
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:
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+21 -7
View File
@@ -3,8 +3,9 @@ name: tests
on:
workflow_call:
inputs:
make:
default: test
custom-pytest:
description: "Custom pytest arguments"
required: true
type: string
python-version:
default: "3.9"
@@ -18,6 +19,9 @@ on:
db-name:
default: "lnbits"
type: string
secrets:
CODECOV_TOKEN:
required: true
jobs:
tests:
@@ -39,20 +43,30 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Run Tests
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: make ${{ inputs.make }}
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: ${{ inputs.custom-pytest }}
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
verbose: false
+4
View File
@@ -11,6 +11,7 @@ __pycache__
*.egg
*.egg-info
.coverage
.coverage.*
.pytest_cache
.webassets-cache
htmlcov
@@ -50,3 +51,6 @@ lnbits-backup.zip
# Ignore extensions (post installable extension PR)
extensions
upgrades/
# builded python package
dist
+3 -3
View File
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 23.7.0
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283
rev: v0.3.2
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
types_or: [css, javascript, html, json]
+4
View File
@@ -11,3 +11,7 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/css/*
flake.lock
.venv
+6 -5
View File
@@ -1,16 +1,17 @@
FROM python:3.10-slim-bullseye
FROM python:3.10-slim-bookworm
RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
# needed for backups postgresql-client version 14 (pg_dump)
RUN apt-get install -y apt-utils wget
RUN echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN apt install -y postgresql-common ca-certificates
RUN install -d /usr/share/postgresql-common/pgdg
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
RUN apt-get update
RUN apt-get install -y postgresql-client-14
+23 -18
View File
@@ -6,8 +6,10 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier:
poetry run ./node_modules/.bin/prettier --write lnbits
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
@@ -25,7 +27,7 @@ checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check lnbits
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
@@ -36,23 +38,32 @@ checkeditorconfig:
dev:
poetry run lnbits --reload
test:
test-wallets:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
poetry run pytest tests/wallets
test-real-wallet:
LNBITS_DATA_FOLDER="./tests/data" \
test-unit:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
poetry run pytest tests/unit
test-api:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
test-migration:
LNBITS_ADMIN_UI=True \
make test
make test-api
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \
@@ -86,7 +97,7 @@ bak:
sass:
npm run sass
bundle_no_bump:
bundle:
npm install
npm run sass
npm run vendor_copy
@@ -97,16 +108,10 @@ bundle_no_bump:
npm run vendor_bundle_js
npm run vendor_minify_js
bundle:
make bundle_no_bump
# increment serviceworker version
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
make bundle_no_bump
make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
@echo "Bundle is OK"
+31 -35
View File
@@ -1,17 +1,16 @@
# LNbits BETA
<picture >
<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">
</picture>
[![license-badge]](LICENSE)
[![docs-badge]][docs]
![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)
![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# The world's most powerful suite of bitcoin tools.
# Free and Open-Source Lightning Wallet Accounts System
## 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 lnbits@pm.me
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
LNbits is a Python server that sits on top of any funding source. It can be used as:
@@ -21,15 +20,7 @@ LNbits is a Python server that sits on top of any funding source. It can be used
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
LNbits can run on top of any Lightning funding source. It currently supports the following, but more and more are added regularly:
- LND (REST and gRPC)
- Core Lightning aka CLN (gRPC, REST and Spark)
- Eclair
- LNPay
- LNbits
- OpenNode
- Alby
- LightningTipBot
LNbits can run on top of almost all Lightning funding sources.
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
@@ -39,40 +30,45 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
## Running LNbits
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.
## LNbits as an account system
## LNbits account system
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..
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
![Lightning network wallet](https://i.imgur.com/w8jdGpF.png)
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
## LNbits extension universe
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
Extend YOUR LNbits to meet YOUR needs.
![lnurl ATM](https://i.imgur.com/WfCg8wY.png)
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.
## LNbits as an LNURL-withdraw fallback
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the [LNURL-withdraw](https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-withdraw.md).
## LNbits API
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilizes LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
## LNbits node manager
## LNbits as an instant wallet
LNbits comes packaged with a light node management UI, to make running your node that much easier.
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## LNbits across all your devices
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## 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-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
---
# API reference
API reference
=============
[Swagger Docs](https://legend.lnbits.com/docs)
[Swagger Docs](https://demo.lnbits.com/docs)
+11 -10
View File
@@ -5,30 +5,27 @@ nav_order: 4
has_children: true
---
For developers
==============
# For developers
Thanks for contributing :)
Run
=====
# Run
This starts the lnbits uvicorn server
```bash
poetry run lnbits
```
This starts the lnbits uvicorn with hot reloading.
```bash
make dev
# or
poetry run lnbits --reload
```
Precommit hooks
=====
# Precommit hooks
This ensures that all commits adhere to the formatting and linting rules.
@@ -36,31 +33,35 @@ This ensures that all commits adhere to the formatting and linting rules.
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:
```bash
poetry install
npm i
```
Then to run the tests:
```bash
make test
```
Run formatting:
```bash
make format
```
Run mypy checks:
```bash
poetry run mypy
```
Run everything:
```bash
make all
```
+21 -16
View File
@@ -5,11 +5,10 @@ title: Making extensions
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`:
```sh
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.
@@ -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'.
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.
1. Edit `manifest.json` and change the organisation name to your GitHub username.
@@ -30,19 +30,17 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
1. ...
1. Profit!!!
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.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.
## 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.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.
... 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 availabe 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.
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
@@ -51,11 +49,9 @@ $ poetry add <package>
```
**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` compatability 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.
@@ -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.
### running migration locally
you will need a running postgres database
#### create lnbits user for migration database
```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
```
#### create migration database
```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
```
#### run the migration
```console
make test-migration
```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again
```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
```
+30 -27
View File
@@ -1,29 +1,32 @@
<html>
<head>
<!-- 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>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
})
window.ui = ui
}
</script>
</body>
<head>
<!-- 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>
<link
rel="stylesheet"
type="text/css"
href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"
/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div>
<!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: 'https://demo.lnbits.com/openapi.json', //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [SwaggerUIBundle.plugins.DownloadUrl]
})
window.ui = ui
}
</script>
</body>
</html>
+2 -5
View File
@@ -5,14 +5,11 @@ title: Websockets
nav_order: 2
---
Websockets
=================
# Websockets
`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:
+15 -13
View File
@@ -4,17 +4,15 @@ title: Admin UI
nav_order: 4
---
# Admin UI
Admin UI
========
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`
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.
# 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.
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`
# Admin Users
Admin Users
===========
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 inaccessable. 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
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.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
# How to activate
How to activate
=============
```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits-legend
$ cd ~/lnbits
$ sudo nano .env
```
-> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
You can now `cat` the Super User ID:
```
$ cat data/.super_user
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.
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.
Do not forget
```
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.
+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)
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.
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
> **Warning**
> Extensions can have bugs or malicious code, be careful what you install!!
## Install New Extension
Only administrator users can install or upgrade 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
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.
## Uninstall Extension
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)
@@ -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.
## 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:
```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:
### Explicit Release
It goes under the `extensions` object and it is of the form:
```json
{
"id": "lnurlp",
"name": "LNURL Pay Links",
"version": 1,
"shortDescription": "Upgrade to version 111111111",
"icon": "receipt",
"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",
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
"infoNotification": "This is a very old version",
"dependencies": ["other-ext-id"]
}
{
"id": "lnurlp",
"name": "LNURL Pay Links",
"version": 1,
"shortDescription": "Upgrade to version 111111111",
"icon": "receipt",
"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",
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
"infoNotification": "This is a very old version",
"dependencies": ["other-ext-id"]
}
```
<details><summary>Fields Detailed Description</summary>
| 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. |
| 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. |
| icon | string | optional | quasar valid icon name |
| 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. |
| 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. |
| 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>
This mode has the advantage of strictly specifying what releases of an extension can be installed.
### GitHub Repository
It goes under the `repos` object and it is of the form:
```json
{
"id": "withdraw",
"organisation": "lnbits",
"repository": "withdraw-extension"
"id": "withdraw",
"organisation": "lnbits",
"repository": "withdraw-extension"
}
```
| 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. |
| organisation | string | The GitHub organisation (eg: `lnbits`) |
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
| 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. |
| organisation | string | The GitHub organisation (eg: `lnbits`) |
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
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)
+8 -5
View File
@@ -4,10 +4,10 @@ title: FAQ
nav_order: 5
---
# FAQ - Frequently Asked Questions
## Install options
<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>
@@ -54,6 +54,7 @@ allow-self-payment=1
</ul>
## Troubleshooting
<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>
<ul>
@@ -83,7 +84,7 @@ allow-self-payment=1
<details><summary>Wallet-URL deleted, are my funds safu ?</summary>
<ul>
<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>
</details>
</li>
@@ -100,7 +101,7 @@ allow-self-payment=1
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
<p>In order to allow comments, add the characters length of the box, from 1 to 250. Once you put a number there,
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
![lnbits-lnurl-comment.png](https://i.postimg.cc/HkJQ9xKr/lnbits-lnurl-comment.png)
@@ -150,12 +151,11 @@ allow-self-payment=1
</details>
<details><summary>Can I configure a name to the payments i make?</summary>
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcion Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcoin Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
![lnbits-tx-details.png](https://i.postimg.cc/yYnvyK4w/lnbits-tx-details.png)
</p>
</details>
<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>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>
## 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>
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
@@ -216,6 +217,7 @@ allow-self-payment=1
</ul>
## Use cases of LNbits
<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><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>
## Developing for LNbits
<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://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
+24 -4
View File
@@ -1,19 +1,23 @@
## Defining a route with path parameters
**old:**
```python
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
**new:**
```python
# with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}")
```
## Check if a user exists and access user object
**old:**
```python
# decorators
@check_user_exists()
@@ -24,14 +28,18 @@ async def do_routing_stuff():
**new:**
If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
If user exists, `user` will be the user object
```python
# depends calls
@core_html_routes.get("/my_route")
async def extensions(user: User = Depends(check_user_exists)):
pass
```
## Returning data from API calls
**old:**
```python
return (
{
@@ -42,9 +50,11 @@ return (
HTTPStatus.OK,
)
```
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:**
```python
return {
"id": wallet.wallet.id,
@@ -54,6 +64,7 @@ return {
```
To change the default HTTPStatus, add it to the path decorator
```python
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
async def payments():
@@ -61,7 +72,9 @@ async def payments():
```
## Raise exceptions
**old:**
```python
return (
{"message": f"Failed to connect to {domain}."},
@@ -74,6 +87,7 @@ abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
**new:**
Raise an exception to return a status code other than the default status code.
```python
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -82,7 +96,9 @@ raise HTTPException(
```
## Extensions
**old:**
```python
from quart import Blueprint
@@ -92,6 +108,7 @@ amilk_ext: Blueprint = Blueprint(
```
**new:**
```python
from fastapi import APIRouter
from lnbits.jinja2_templating import Jinja2Templates
@@ -114,9 +131,12 @@ offlineshop_rndr = template_renderer([
```
## Possible optimizations
### 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.
Example:
* Get transactions for a wallet ID
* User data for a user id
* Wallet data for a Admin / Invoice key
- Get transactions for a wallet ID
- User data for a user id
- Wallet data for a Admin / Invoice key
+37 -17
View File
@@ -13,21 +13,31 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
## Option 1 (recommended): poetry
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
Make sure you have Python 3.9 or 3.10 installed.
### install python on ubuntu
```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
```
### install poetry
```sh
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
export PATH="/home/user/.local/bin:$PATH"
```
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
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
export PATH="/home/user/.local/bin:$PATH"
# 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
@@ -48,6 +58,7 @@ poetry run lnbits
# 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.
```
#### Updating the server
```
@@ -89,7 +100,7 @@ nix run
Ideally you would set the environment via the `.env` file,
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.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
@@ -100,13 +111,16 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
## Option 3: Docker
use latest version from docker hub
```sh
docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
build the image yourself
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
@@ -149,7 +163,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.
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>`.
@@ -246,11 +260,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.
# Additional guides
## 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.
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:
@@ -272,7 +285,6 @@ make migration
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## 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:
@@ -449,28 +461,36 @@ service restart nginx
```
## 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.
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
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu:
```sh
sudo apt install libnss3-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
#### Create certificate
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
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 `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh
# 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
@@ -482,7 +502,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
```
## 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.
@@ -494,7 +513,7 @@ To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
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
@@ -506,6 +525,7 @@ cp <lnbits_repo>/.env.example .env
and change the configuration in `.env` as required.
Then create the data directory
```
mkdir data
```
@@ -513,7 +533,7 @@ mkdir data
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.
+19 -5
View File
@@ -4,15 +4,12 @@ title: Backend wallets
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:
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -87,6 +84,23 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### 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
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `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
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
+7 -11
View File
@@ -4,21 +4,17 @@ title: Users Guide
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:
* 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
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
- 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
- Part of a development stack via LNbits API
- Fallback wallet for the LNURL scheme
- 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,
export to csv + more to come...
+10
View File
@@ -33,6 +33,16 @@
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
types-mock = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
});
};
});
+123 -317
View File
@@ -1,45 +1,47 @@
import asyncio
import glob
import importlib
import logging
import os
import shutil
import signal
import sys
import traceback
from hashlib import sha256
from http import HTTPStatus
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse
from lnbits.core.crud import get_dbversions, get_installed_extensions
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services import websocketUpdater
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
register_killswitch,
register_task_listeners,
from lnbits.core.tasks import ( # watchdog_task
killswitch_task,
wait_for_paid_invoices,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.settings import settings
from lnbits.tasks import cancel_all_tasks, create_permanent_task
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
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 .core import init_core_routers
from .core.db import core_app_extra
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.api import add_installed_extension
from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
@@ -47,7 +49,6 @@ from .extension_manager import (
get_valid_extensions,
version_parse,
)
from .helpers import template_renderer
from .middleware import (
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
@@ -61,10 +62,63 @@ from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
webhook_handler,
)
async def startup(app: FastAPI):
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
await check_webpush_settings()
log_server_info()
# initialize WALLET
try:
set_funding_source()
except Exception as e:
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
set_void_wallet_class()
# initialize funding source
await check_funding_source()
# register core routes
init_core_routers(app)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_installed_extensions(app)
register_all_ext_routes(app)
# initialize tasks
register_async_tasks()
async def shutdown():
logger.warning("LNbits shutting down...")
settings.lnbits_running = False
# shutdown event
cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
funding_source = get_funding_source()
await funding_source.cleanup()
@asynccontextmanager
async def lifespan(app: FastAPI):
await startup(app)
yield
await shutdown()
def create_app() -> FastAPI:
configure_logger()
app = FastAPI(
@@ -74,6 +128,7 @@ def create_app() -> FastAPI:
"accounts system with plugins."
),
version=settings.version,
lifespan=lifespan,
license_info={
"name": "MIT License",
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
@@ -81,8 +136,8 @@ def create_app() -> FastAPI:
)
# 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))
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
static_path = Path("lnbits", "static")
@@ -114,65 +169,50 @@ def create_app() -> FastAPI:
add_ip_block_middleware(app)
add_ratelimit_middleware(app)
register_startup(app)
register_async_tasks(app)
register_exception_handlers(app)
register_shutdown(app)
return app
async def check_funding_source() -> None:
original_sigint_handler = signal.getsignal(signal.SIGINT)
funding_source = get_funding_source()
def signal_handler(signal, frame):
logger.debug(
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
)
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
WALLET = get_wallet_class()
# fallback to void after 30 seconds of failures
sleep_time = 5
timeout = int(30 / sleep_time)
balance = 0
max_retries = settings.funding_source_max_retries
retry_counter = 0
while True:
while settings.lnbits_running:
try:
error_message, balance = await WALLET.status()
logger.info(f"Connecting to backend {funding_source.__class__.__name__}...")
error_message, balance = await funding_source.status()
if not error_message:
retry_counter = 0
logger.success(
f"✔️ Backend {funding_source.__class__.__name__} connected "
f"and with a balance of {balance} msat."
)
break
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}'",
RuntimeWarning,
)
except Exception as e:
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
pass
logger.error(
f"Error connecting to {funding_source.__class__.__name__}: {e}"
)
if settings.lnbits_admin_ui and retry_counter == timeout:
if retry_counter >= max_retries:
set_void_wallet_class()
WALLET = get_wallet_class()
funding_source = get_funding_source()
break
else:
logger.warning(f"Retrying connection to backend in {sleep_time} seconds...")
retry_counter += 1
await asyncio.sleep(sleep_time)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.success(
f"✔️ Backend {WALLET.__class__.__name__} connected "
f"and with a balance of {balance} msat."
)
retry_counter += 1
sleep_time = 0.25 * (2**retry_counter)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
)
await asyncio.sleep(sleep_time)
def set_void_wallet_class():
@@ -180,7 +220,7 @@ def set_void_wallet_class():
"Fallback to VoidWallet, because the backend for "
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):
@@ -195,7 +235,7 @@ async def check_installed_extensions(app: FastAPI):
for ext in installed_extensions:
try:
installed = check_installed_extension_files(ext)
installed = await check_installed_extension_files(ext)
if not installed:
await restore_installed_extension(app, ext)
logger.info(
@@ -253,14 +293,14 @@ async def build_all_installed_extensions_list(
]
def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
if ext.has_installed_version:
return True
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
if f"./{str(ext.zip_path)}" not in zip_files:
ext.download_archive()
if f"./{ext.zip_path!s}" not in zip_files:
await ext.download_archive()
ext.extract_archive()
return False
@@ -279,18 +319,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext.nofiy_upgrade()
def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions."""
init_core_routers(app)
for ext in get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as e:
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
ext.notify_upgrade()
def register_custom_extensions_path():
@@ -368,253 +397,30 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
def register_startup(app: FastAPI):
@app.on_event("startup")
async def lnbits_startup():
def register_all_ext_routes(app: FastAPI):
for ext in get_valid_extensions(False):
try:
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
await check_webpush_settings()
log_server_info()
# initialize WALLET
try:
set_wallet_class()
except Exception as e:
logger.error(
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
)
set_void_wallet_class()
# initialize funding source
await check_funding_source()
# check extensions after restart
await check_installed_extensions(app)
# register core and extension routes
register_routes(app)
if settings.lnbits_admin_ui:
initialize_server_logger()
register_ext_routes(app, ext)
except Exception as e:
logger.error(str(e))
raise ImportError("Failed to run 'startup' event.")
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
def register_shutdown(app: FastAPI):
@app.on_event("shutdown")
async def on_shutdown():
cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
WALLET = get_wallet_class()
await WALLET.cleanup()
def register_async_tasks():
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
# core invoice listener
invoice_queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
def initialize_server_logger():
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
serverlog_queue = asyncio.Queue()
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(app):
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.on_event("startup")
async def listeners():
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
register_task_listeners()
register_killswitch()
# await run_deferred_async() # calle: doesn't do anyting?
def register_exception_handlers(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: {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(
"error.html", {"request": request, "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(
"error.html",
{"request": request, "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", samesite="none", secure=True
)
return response
return template_renderer().TemplateResponse(
"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
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())
# server logs for websocket
if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger()
create_permanent_task(server_log_task)
+144 -16
View File
@@ -1,7 +1,9 @@
import asyncio
import importlib
import time
from functools import wraps
from pathlib import Path
from typing import Optional, Tuple
from typing import List, Optional, Tuple
from urllib.parse import urlparse
import click
@@ -10,21 +12,29 @@ from fastapi.exceptions import HTTPException
from loguru import logger
from packaging import version
from lnbits.core.models import User
from lnbits.core.models import Payment, User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_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
@@ -73,7 +83,7 @@ def get_super_user() -> Optional[str]:
"Superuser id not found. Please check that the file "
+ 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()
@@ -114,7 +124,8 @@ def database_migrate():
async def db_migrate():
asyncio.create_task(migrate_databases())
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
@@ -180,6 +191,37 @@ async def database_cleanup_deleted_wallets():
await remove_deleted_wallets(conn)
@db.command("delete-wallet")
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
@coro
async def database_delete_wallet(wallet: str):
"""Mark wallet as deleted"""
async with core_db.connect() as conn:
count = await delete_wallet_by_id(wallet_id=wallet, conn=conn)
click.echo(f"Marked as deleted '{count}' rows.")
@db.command("delete-wallet-payment")
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_delete_wallet_payment(wallet: str, checking_id: str):
"""Delete wallet payment"""
async with core_db.connect() as conn:
await delete_wallet_payment(
wallet_id=wallet, checking_id=checking_id, conn=conn
)
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
@@ -191,6 +233,86 @@ async def database_cleanup_accounts(days: Optional[int] = None):
await delete_accounts_no_wallets(delta, conn)
@db.command("check-payments")
@click.option("-d", "--days", help="Maximum age of payments in days.")
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
@click.option("-w", "--wallet", help="Only check for this wallet.")
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro
async def check_invalid_payments(
days: Optional[int] = None,
limit: Optional[int] = None,
wallet: Optional[str] = None,
verbose: Optional[bool] = False,
):
"""Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings()
settled_db_payments = []
if verbose:
click.echo(f"Get Payments: days={days}, limit={limit}, wallet={wallet}")
async with core_db.connect() as conn:
delta = int(days) if days else 3 # default to 3 days
limit = int(limit) if limit else 1000
since = int(time.time()) - delta * 24 * 60 * 60
settled_db_payments = await get_payments(
complete=True,
incoming=True,
exclude_uncheckable=True,
since=since,
limit=limit,
wallet_id=wallet,
conn=conn,
)
click.echo("Settled Payments: " + str(len(settled_db_payments)))
wallets_module = importlib.import_module("lnbits.wallets")
wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
funding_source: Wallet = wallet_class()
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
click.echo(
f"Checking Payment: '{db_payment.checking_id}' for wallet"
+ f" '{db_payment.wallet_id}'."
)
payment_status = await funding_source.get_invoice_status(db_payment.checking_id)
if payment_status.pending:
invalid_payments.append(db_payment)
if db_payment.wallet_id not in invalid_wallets:
invalid_wallets[f"{db_payment.wallet_id}"] = [0, 0]
invalid_wallets[f"{db_payment.wallet_id}"][0] += 1
invalid_wallets[f"{db_payment.wallet_id}"][1] += db_payment.amount
click.echo(
"Invalid Payment: '"
+ " ".join(
[
db_payment.checking_id,
db_payment.wallet_id,
str(db_payment.amount / 1000).ljust(10),
db_payment.memo or "",
]
)
+ "'"
)
click.echo("Invalid Payments: " + str(len(invalid_payments)))
click.echo("\nInvalid Wallets: " + str(len(invalid_wallets)))
for w in invalid_wallets:
data = invalid_wallets[f"{w}"]
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()
@@ -212,7 +334,7 @@ async def extensions_list():
@extensions.command("update")
@click.argument("extension", required=False)
@click.option("-a", "--all", is_flag=True, help="Update all extensions.")
@click.option("-a", "--all-extensions", is_flag=True, help="Update all extensions.")
@click.option(
"-i", "--repo-index", help="Select the index of the repository to be used."
)
@@ -239,7 +361,7 @@ async def extensions_list():
@coro
async def extensions_update(
extension: Optional[str] = None,
all: Optional[bool] = False,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
@@ -249,11 +371,11 @@ async def extensions_update(
Update extension to the latest version.
If an extension is not present it will be instaled.
"""
if not extension and not all:
if not extension and not all_extensions:
click.echo("Extension ID is required.")
click.echo("Or specify the '--all' flag to update all extensions")
click.echo("Or specify the '--all-extensions' flag to update all extensions")
return
if extension and all:
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not _is_url(url):
@@ -371,7 +493,7 @@ async def extensions_uninstall(
click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.")
return False, ex.detail
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)
@@ -397,7 +519,10 @@ async def install_extension(
return False, "No release selected"
data = CreateExtension(
ext_id=extension, archive=release.archive, source_repo=release.source_repo
ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
await _call_install_extension(data, url, admin_user)
click.echo(f"Extension '{extension}' ({release.version}) installed.")
@@ -406,7 +531,7 @@ async def install_extension(
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
return False, ex.detail
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)
@@ -445,7 +570,10 @@ async def update_extension(
click.echo(f"Updating '{extension}' extension to version: {release.version }")
data = CreateExtension(
ext_id=extension, archive=release.archive, source_repo=release.source_repo
ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
await _call_install_extension(data, url, admin_user)
@@ -455,7 +583,7 @@ async def update_extension(
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
return False, ex.detail
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)
@@ -478,7 +606,7 @@ async def _select_release(
return latest_repo_releases[source_repo]
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.sort()
+16 -6
View File
@@ -1,30 +1,40 @@
from fastapi import APIRouter
from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.auth_api import auth_router
from .views.extension_api import extension_router
# this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
from .views.tinyurl_api import tinyurl_router
from .views.user_api import users_router
from .views.wallet_api import wallet_router
from .views.webpush_api import webpush_router
from .views.websocket_api import websocket_router
# backwards compatibility for extensions
core_app = APIRouter(tags=["Core"])
def init_core_routers(app):
def init_core_routers(app: FastAPI):
app.include_router(core_app)
app.include_router(generic_router)
app.include_router(public_router)
app.include_router(api_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(node_router)
app.include_router(extension_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(admin_router)
app.include_router(public_router)
app.include_router(payment_router)
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(auth_router)
app.include_router(users_router)
+112 -82
View File
@@ -2,14 +2,12 @@ import datetime
import json
from time import time
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse
from uuid import UUID, uuid4
import shortuuid
from passlib.context import CryptContext
from lnbits.core.db import db
from lnbits.core.models import WalletType
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension
from lnbits.settings import (
@@ -21,7 +19,8 @@ from lnbits.settings import (
)
from .models import (
BalanceCheck,
Account,
AccountFilters,
CreateUser,
Payment,
PaymentFilters,
@@ -147,6 +146,46 @@ async def update_account(
return user
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = ?",
(user_id,),
)
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Account]:
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
[],
[],
filters=filters,
model=Account,
group_by=["accounts.id"],
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[User]:
@@ -168,14 +207,21 @@ async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND updated_at < {db.timestamp_placeholder}
) AND (
(updated_at is null AND created_at < {db.timestamp_placeholder})
OR updated_at < {db.timestamp_placeholder}
)
""",
(int(time()) - time_delta,),
(
delta,
delta,
),
)
@@ -322,6 +368,7 @@ async def add_installed_extension(
dict(ext.installed_release) if ext.installed_release else None
),
"dependencies": ext.dependencies,
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
}
version = ext.installed_release.version if ext.installed_release else ""
@@ -499,19 +546,47 @@ async def update_wallet(
async def delete_wallet(
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder}
SET deleted = ?, updated_at = {db.timestamp_placeholder}
WHERE id = ? AND "user" = ?
""",
(now, wallet_id, user_id),
(deleted, now, wallet_id, user_id),
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = ?",
(wallet_id,),
)
async def delete_wallet_by_id(
*, wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder}
WHERE id = ?
""",
(now, wallet_id),
)
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
@@ -520,14 +595,21 @@ async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
f"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
) = 0 AND updated_at < {db.timestamp_placeholder}
) = 0 AND (
(updated_at is null AND created_at < {db.timestamp_placeholder})
OR updated_at < {db.timestamp_placeholder}
)
""",
(int(time()) - time_delta,),
(
delta,
delta,
),
)
@@ -545,15 +627,27 @@ async def get_wallet(
return Wallet(**row) if row else None
async def get_wallets(user_id: str, conn: Optional[Connection] = None) -> List[Wallet]:
rows = await (conn or db).fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
AS balance_msat FROM wallets WHERE "user" = ?
""",
(user_id,),
)
return [Wallet(**row) for row in rows]
async def get_wallet_for_key(
key: str,
key_type: WalletType = WalletType.invoice,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
row = await (conn or db).fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
AS balance_msat FROM wallets
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
""",
(key, key),
)
@@ -561,9 +655,6 @@ async def get_wallet_for_key(
if not row:
return None
if key_type == WalletType.admin and row["adminkey"] != key:
return None
return Wallet(**row)
@@ -572,11 +663,6 @@ async def get_total_balance(conn: Optional[Connection] = None):
return 0 if row[0] is None else row[0]
async def get_active_wallet_total_balance(conn: Optional[Connection] = None):
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
return 0 if row[0] is None else row[0]
# wallet payments
# ---------------
@@ -601,6 +687,7 @@ async def get_standalone_payment(
SELECT *
FROM apipayments
WHERE {clause}
ORDER BY amount
LIMIT 1
""",
tuple(values),
@@ -1012,73 +1099,16 @@ async def check_internal_pending(
return row["pending"]
# balance_check
# -------------
async def save_balance_check(
wallet_id: str, url: str, conn: Optional[Connection] = None
):
domain = urlparse(url).netloc
await (conn or db).execute(
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
INSERT INTO balance_check (wallet, service, url) VALUES (?, ?, ?)
ON CONFLICT (wallet, service) DO UPDATE SET url = ?
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(wallet_id, domain, url, url),
(status, payment_hash),
)
async def get_balance_check(
wallet_id: str, domain: str, conn: Optional[Connection] = None
) -> Optional[BalanceCheck]:
row = await (conn or db).fetchone(
"""
SELECT wallet, service, url
FROM balance_check
WHERE wallet = ? AND service = ?
""",
(wallet_id, domain),
)
return BalanceCheck.from_row(row) if row else None
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
return [BalanceCheck.from_row(row) for row in rows]
# balance_notify
# --------------
async def save_balance_notify(
wallet_id: str, url: str, conn: Optional[Connection] = None
):
await (conn or db).execute(
"""
INSERT INTO balance_notify (wallet, url) VALUES (?, ?)
ON CONFLICT (wallet) DO UPDATE SET url = ?
""",
(wallet_id, url, url),
)
async def get_balance_notify(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[str]:
row = await (conn or db).fetchone(
"""
SELECT url
FROM balance_notify
WHERE wallet = ?
""",
(wallet_id,),
)
return row[0] if row else None
# admin
# --------
+47 -6
View File
@@ -18,11 +18,11 @@ async def migrate_extension_database(ext: Extension, current_version):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as e:
logger.error(e)
except ImportError as exc:
logger.error(exc)
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
) from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
@@ -53,8 +53,45 @@ async def stop_extension_background_work(
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
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}"
@@ -63,7 +100,11 @@ async def stop_extension_background_work(
)
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)
@@ -72,7 +113,7 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("User ID must have at least 128 bits")
try:
int(user_id, 16)
except Exception:
raise ValueError("Invalid hex string for User ID.")
except Exception as exc:
raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4)
+41
View File
@@ -471,3 +471,44 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
except OperationalError as exc:
logger.error(f"Migration 17 failed: {exc}")
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
"""
)
+55 -23
View File
@@ -17,18 +17,21 @@ from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class
from lnbits.wallets.base import PaymentStatus
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
class Wallet(BaseModel):
class BaseWallet(BaseModel):
id: str
name: str
user: str
adminkey: str
inkey: str
currency: Optional[str]
balance_msat: int
class Wallet(BaseWallet):
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@@ -65,7 +68,7 @@ class Wallet(BaseModel):
return await get_standalone_payment(payment_hash)
class WalletType(Enum):
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
@@ -77,7 +80,7 @@ class WalletType(Enum):
@dataclass
class WalletTypeInfo:
wallet_type: WalletType
key_type: KeyType
wallet: Wallet
@@ -94,6 +97,37 @@ class UserConfig(BaseModel):
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["id", "email", "username"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
id: str
last_payment: Optional[datetime.datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
email: Optional[str] = None
class User(BaseModel):
id: str
email: Optional[str] = None
@@ -255,18 +289,18 @@ class Payment(FromRowModel):
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentStatus(None)
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
WALLET = get_wallet_class()
funding_source = get_funding_source()
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
status = await funding_source.get_payment_status(self.checking_id)
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}")
@@ -328,16 +362,6 @@ class PaymentHistoryPoint(BaseModel):
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(*_):
pass
@@ -391,11 +415,10 @@ class CreateInvoice(BaseModel):
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
class CreateTopup(BaseModel):
@@ -421,3 +444,12 @@ class WebPushSubscription(BaseModel):
data: str
host: str
timestamp: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
+85 -72
View File
@@ -8,7 +8,6 @@ from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
import httpx
from bolt11 import Bolt11
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
@@ -30,8 +29,13 @@ from lnbits.settings import (
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from .crud import (
check_internal,
@@ -54,15 +58,19 @@ from .crud import (
update_super_user,
)
from .helpers import to_valid_user_id
from .models import Payment, UserConfig, Wallet
from .models import BalanceDelta, Payment, UserConfig, Wallet
class PaymentFailure(Exception):
pass
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceFailure(Exception):
pass
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
async def calculate_fiat_amounts(
@@ -118,16 +126,16 @@ async def create_invoice(
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
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)
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
# 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, wallet_id, currency=currency, extra=extra, conn=conn
@@ -136,12 +144,18 @@ async def create_invoice(
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceFailure(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats."
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
@@ -149,7 +163,9 @@ async def create_invoice(
expiry=expiry or settings.lightning_invoice_expiry,
)
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)
@@ -160,7 +176,7 @@ async def create_invoice(
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
expiry=get_bolt11_expiry(invoice),
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
@@ -189,12 +205,15 @@ async def pay_invoice(
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
invoice = bolt11_decode(payment_request)
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise ValueError("Amountless invoices not supported.")
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("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)
@@ -202,11 +221,6 @@ async def pay_invoice(
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
@@ -226,7 +240,7 @@ async def pay_invoice(
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=get_bolt11_expiry(invoice),
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
@@ -234,7 +248,7 @@ async def pay_invoice(
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise 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
# (pending only)
@@ -253,7 +267,7 @@ async def pay_invoice(
internal_invoice.amount != invoice.amount_msat
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}")
# create a new payment from this wallet
@@ -278,10 +292,10 @@ async def pay_invoice(
conn=conn,
**payment_kwargs,
)
except Exception as e:
logger.error(f"could not create temporary payment: {e}")
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 PaymentFailure("Could not make payment.")
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
@@ -292,11 +306,12 @@ async def pay_invoice(
not internal_checking_id
and wallet.balance_msat > -fee_reserve_total_msat
):
raise PaymentFailure(
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees."
" sat) to cover potential routing fees.",
status="failed",
)
raise PermissionError("Insufficient balance.")
raise PaymentError("Insufficient balance.", status="failed")
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
@@ -320,8 +335,8 @@ async def pay_invoice(
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
WALLET = get_wallet_class()
payment: PaymentResponse = await WALLET.pay_invoice(
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
@@ -332,6 +347,7 @@ async def pay_invoice(
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
@@ -360,9 +376,10 @@ async def pay_invoice(
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentFailure(
raise PaymentError(
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:
logger.warning(
@@ -405,8 +422,9 @@ async def check_time_limit_between_transactions(conn, wallet_id):
if len(payments) == 0:
return
raise ValueError(
f"The time limit of {limit} seconds between payments has been reached."
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
@@ -496,7 +514,6 @@ async def redeem_lnurl_withdraw(
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
conn: Optional[Connection] = None,
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
@@ -579,17 +596,17 @@ async def check_transaction_status(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentStatus(None)
return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again
return PaymentStatus(True, fee_msat=payment.fee)
return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to
# 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:
if internal:
return 0
@@ -618,7 +635,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocketUpdater(
await websocket_updater(
wallet.id,
json.dumps(
{
@@ -711,13 +728,16 @@ async def check_webpush_settings():
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key not in readonly_variables:
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
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:
@@ -754,33 +774,26 @@ class WebsocketConnectionManager:
await connection.send_text(message)
websocketManager = WebsocketConnectionManager()
websocket_manager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id)
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
WALLET = get_wallet_class()
if WALLET.__class__.__name__ == "VoidWallet":
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_wallet_class("VoidWallet")
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> Tuple[int, int, int]:
WALLET = get_wallet_class()
total_balance = await get_total_balance()
error_message, node_balance = await WALLET.status()
if error_message:
raise Exception(error_message)
return node_balance - total_balance, node_balance, total_balance
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
if invoice.expiry:
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
else:
# assume maximum bolt11 expiry of 31 days to be on the safe side
return datetime.datetime.now() + datetime.timedelta(days=31)
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
+45 -82
View File
@@ -5,44 +5,33 @@ import httpx
from loguru import logger
from lnbits.core.crud import (
get_balance_notify,
get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.db import db
from lnbits.core.models import Payment
from lnbits.core.services import (
get_balance_delta,
send_payment_notification,
switch_to_voidwallet,
)
from lnbits.settings import get_wallet_class, settings
from lnbits.tasks import (
SseListenersDict,
create_permanent_task,
create_task,
register_invoice_listener,
send_push_notification,
)
from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
"api_invoice_listeners"
)
def register_killswitch():
"""
Registers a killswitch which will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
logger.debug("Starting killswitch task")
create_permanent_task(killswitch_task)
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
async def killswitch_task():
while True:
WALLET = get_wallet_class()
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
"""
killswitch will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
while settings.lnbits_running:
funding_source = get_funding_source()
if (
settings.lnbits_killswitch
and funding_source.__class__.__name__ != "VoidWallet"
):
with httpx.Client() as client:
try:
r = client.get(settings.lnbits_status_manifest, timeout=4)
@@ -54,7 +43,7 @@ async def killswitch_task():
"Switching to VoidWallet. Killswitch triggered."
)
await switch_to_voidwallet()
except (httpx.ConnectError, httpx.RequestError):
except (httpx.RequestError, httpx.HTTPStatusError):
logger.error(
"Cannot fetch lnbits status manifest."
f" {settings.lnbits_status_manifest}"
@@ -62,22 +51,20 @@ async def killswitch_task():
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
async def register_watchdog():
async def watchdog_task():
"""
Registers a watchdog which will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached.
"""
# TODO: implement watchdog properly
# logger.debug("Starting watchdog task")
# create_permanent_task(watchdog_task)
async def watchdog_task():
while True:
WALLET = get_wallet_class()
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
while settings.lnbits_running:
funding_source = get_funding_source()
if (
settings.lnbits_watchdog
and funding_source.__class__.__name__ != "VoidWallet"
):
try:
delta, *_ = await get_balance_delta()
balance = await get_balance_delta()
delta = balance.delta_msats
logger.debug(f"Running watchdog task. current delta: {delta}")
if delta + settings.lnbits_watchdog_delta <= 0:
logger.error(f"Switching to VoidWallet. current delta: {delta}")
@@ -87,47 +74,23 @@ async def watchdog_task():
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
def register_task_listeners():
"""
Registers an invoice listener queue for the core tasks. Incoming payments in this
queue will eventually trigger the signals sent to all other extensions
and fulfill other core tasks such as dispatching webhooks.
"""
invoice_paid_queue = asyncio.Queue(5)
# we register invoice_paid_queue to receive all incoming invoices
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
# register a worker that will react to invoices
create_task(wait_for_paid_invoices(invoice_paid_queue))
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
"""
This worker dispatches events to all extensions,
dispatches webhooks and balance notifys.
This worker dispatches events to all extensions and dispatches webhooks.
"""
while True:
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# send information to sse channel
# dispatch api_invoice_listeners
await dispatch_api_invoice_listeners(payment)
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
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)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
pass
# dispatch push notification
await send_payment_push_notification(payment)
@@ -137,10 +100,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
"""
for chan_name, send_channel in api_invoice_listeners.items():
try:
logger.debug(f"sending invoice paid event to {chan_name}")
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
send_channel.put_nowait(payment)
except asyncio.QueueFull:
logger.error(f"removing sse listener {send_channel}:{chan_name}")
logger.error(
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
)
api_invoice_listeners.pop(chan_name)
@@ -151,26 +116,24 @@ async def dispatch_webhook(payment: Payment):
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment, -1)
return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
r = await client.post(payment.webhook, json=data, timeout=40)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(status, payment.payment_hash),
)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_push_notification(payment: Payment):
+15 -15
View File
@@ -7,21 +7,21 @@
<div class="col">
<p>Funding Source Info</p>
<ul>
{%raw%}
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
<li>
Node Balance: {{(auditData.node_balance_msats /
1000).toLocaleString()}} sats
</li>
<li>
LNbits Balance: {{(auditData.lnbits_balance_msats /
1000).toLocaleString()}} sats
</li>
<li>
Reserve Percent: {{(auditData.node_balance_msats /
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
</li>
{%endraw%}
<li
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
></li>
<li
v-text="'Node Balance: ' + (auditData.node_balance_msats /
1000).toLocaleString() + ' sats'"
></li>
<li
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
1000).toLocaleString() + ' sats'"
></li>
<li
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
></li>
</ul>
<br />
</div>
+5 -11
View File
@@ -131,7 +131,7 @@
style="padding: 10px; color: #fafafa; height: 320px"
>
<small v-for="log in logs"
>{% raw %}{{ log }}{% endraw %}<br
><span v-text="log"></span><br
/></small>
</q-scroll-area>
</div>
@@ -166,7 +166,6 @@
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="blocked_ip in formData.lnbits_blocked_ips"
:key="blocked_ip"
@@ -174,10 +173,8 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
>
{{ blocked_ip }}
</q-chip>
{%endraw%}
v-text="blocked_ip"
></q-chip>
</div>
<br />
</div>
@@ -198,7 +195,6 @@
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="allowed_ip in formData.lnbits_allowed_ips"
:key="allowed_ip"
@@ -206,10 +202,8 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
>
{{ allowed_ip }}
</q-chip>
{%endraw%}
v-text="allowed_ip"
></q-chip>
</div>
<br />
</div>
@@ -1,4 +1,3 @@
{% raw %}
<q-banner v-if="updateAvailable" class="bg-primary text-white">
<q-icon size="28px" name="update"></q-icon>
@@ -30,9 +29,12 @@
<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"
>{{ col.label }}</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">
@@ -51,12 +53,16 @@
color="red"
></q-icon>
</q-td>
<q-td auto-width key="date" :props="props">
{{ formatDate(props.row.date) }}
<q-td
auto-width
key="date"
:props="props"
v-text="formatDate(props.row.date)"
>
</q-td>
<q-td key="message" :props="props"
>{{ props.row.message }}
<a
><span v-text="props.row.message"></span
><a
v-if="props.row.link"
target="_blank"
rel="noopener noreferrer"
@@ -69,4 +75,3 @@
</q-table>
</q-card-section>
</q-card>
{% endraw %}
+25 -13
View File
@@ -7,14 +7,14 @@
<div class="col">
<p>Server Info</p>
<ul>
{%raw%}
<li v-if="settings.lnbits_data_folder">
SQlite: {{settings.lnbits_data_folder}}
</li>
<li v-if="settings.lnbits_database_url">
Postgres: {{settings.lnbits_database_url}}
</li>
{%endraw%}
<li
v-if="settings.lnbits_data_folder"
v-text="'SQlite: ' + settings.lnbits_data_folder"
></li>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<br />
</div>
@@ -60,6 +60,21 @@
</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>
@@ -154,7 +169,6 @@
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
@@ -162,10 +176,8 @@
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
>
{{ manifestUrl }}
</q-chip>
{%endraw%}
><span v-text="manifestUrl"></span
></q-chip>
</div>
<br />
</div>
+35 -5
View File
@@ -4,7 +4,7 @@
<br />
<div>
<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>
<q-input
filled
@@ -14,7 +14,7 @@
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<div class="col-12 col-md-5">
<p>Site Tagline</p>
<q-input
filled
@@ -24,7 +24,15 @@
></q-input>
<br />
</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>
<p>Site Description</p>
<q-input
@@ -44,7 +52,6 @@
v-model="formData.lnbits_default_wallet_name"
label="LNbits wallet"
></q-input>
<br />
</div>
<div class="col-12 col-md-4">
<p>Denomination</p>
@@ -55,7 +62,6 @@
label="sats"
hint="The name for the FakeWallet token"
></q-input>
<br />
</div>
<div class="col-12 col-md-4">
<p>QR code logo</p>
@@ -66,9 +72,33 @@
label="https://example.com/image.svg"
hint="URL to logo image in QR code"
></q-input>
<br />
</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="col-12 col-md-6">
<p>Themes</p>
+2 -2
View File
@@ -23,8 +23,8 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
v-text="user"
>
<span v-text="user"></span>
</q-chip>
</div>
<br />
@@ -49,8 +49,8 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
v-text="user"
>
<span v-text="user" />
</q-chip>
</div>
<br />
+9 -64
View File
@@ -8,9 +8,9 @@
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges"
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
@@ -27,9 +27,9 @@
color="primary"
@click="restartServer"
>
<q-tooltip v-if="needsRestart"
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="needsRestart"
@@ -40,15 +40,6 @@
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('topup')"
color="primary"
@click="topUpDialog.show = true"
>
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
</q-btn>
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
<q-btn
@@ -59,7 +50,9 @@
@click="deleteSettings"
class="float-right"
>
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
@@ -115,54 +108,6 @@
</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) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
{% endblock %}
@@ -5,6 +5,7 @@
:content-inset-level="0.5"
>
<q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
+369 -97
View File
@@ -2,7 +2,7 @@
%} {{ window_vars(user, extensions) }}{% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-sm-9 col-xs-12">
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p>
<p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
</div>
<div class="col-sm-3 col-xs-12 q-ml-auto">
@@ -43,9 +43,10 @@
:label="$t('featured')"
@update="val => tab = val.name"
></q-tab>
<i v-if="!g.user.admin && tab != 'installed'"
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i
>
<i
v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')"
></i>
</q-tabs>
</div>
</div>
@@ -89,45 +90,42 @@
color="green"
class="float-right"
>
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small>
<small v-text="$t('new_version')"></small>
<q-tooltip
><span v-text="extension.latestRelease.version"></span
></q-tooltip>
</q-badge>
{% raw %}
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
{{ extension.name }}
</div>
<div
class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"
v-text="extension.name"
></div>
<div
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
style="min-height: 60px"
>
{{ extension.name }}
</div>
v-text="extension.name"
></div>
<div
class="text-subtitle2 gt-sm"
style="font-size: 11px; height: 34px"
>
{{ extension.shortDescription ||
extension.installedRelease?.description }}
</div>
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
{{ extension.name }}
</div>
v-text="extension.shortDescription || extension.installedRelease?.description"
></div>
<div
class="text-subtitle1 lt-md q-mt-sm q-mb-xs"
v-text="extension.name"
></div>
<div
class="text-subtitle2 lt-md"
style="font-size: 9px; height: 34px"
>
{{ extension.shortDescription }}
</div>
{% endraw %}
v-text="extension.shortDescription"
></div>
</div>
</div>
<div class="row q-pt-sm">
<div class="col">
<small v-if="extension.dependencies?.length"
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</small
>
<small
v-if="extension.dependencies?.length"
v-text="$t('extension_depends_on')"
></small>
<small v-else>&nbsp;</small>
<q-badge
v-for="dep in extension.dependencies"
@@ -148,20 +146,18 @@
size="1.5em"
:max="5"
color="primary"
><q-tooltip
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
></q-rating
>
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
<q-rating
v-model="maxStars"
class="lt-md"
size="1.5em"
:max="5"
color="primary"
><q-tooltip
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
></q-rating
>
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
<q-toggle
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') "
@@ -169,11 +165,11 @@
style="max-height: 21px"
v-model="extension.isActive"
@input="toggleExtension(extension)"
><q-tooltip
>{%raw%}{{ $t('activate_extension_details')
}}{%endraw%}</q-tooltip
></q-toggle
>
><q-tooltip>
<span
v-text="$t('activate_extension_details')"
></span> </q-tooltip
></q-toggle>
</div>
</q-card-section>
<q-separator></q-separator>
@@ -186,8 +182,8 @@
color="primary"
type="a"
:href="extension.id + '/'"
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
>
:label="$t('open')"
></q-btn>
<q-btn
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
@@ -196,11 +192,12 @@
:href="['{{
url_for('install.extensions')
}}', '?disable=', extension.id].join('')"
:label="$t('disable')"
></q-btn>
<q-badge
v-if="extension.isAdminOnly && !user.admin"
v-text="$t('admin_only')"
>
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
>
<q-badge v-if="extension.isAdminOnly && !user.admin">
{%raw%}{{ $t('admin_only') }}{%endraw%}
</q-badge>
<q-btn
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
@@ -210,8 +207,8 @@
:href="['{{
url_for('install.extensions')
}}', '?enable=', extension.id].join('')"
:label="$t('enable')"
>
{%raw%}{{ $t('enable') }}{%endraw%}
<q-tooltip>
<span v-text="$t('enable_extension_details')">
</span> </q-tooltip
@@ -222,12 +219,11 @@
flat
color="primary"
v-if="g.user.admin"
>
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip
>{%raw%}{{ $t('manage_extension_details')
}}{%endraw%}</q-tooltip
></q-btn
>
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
></q-tooltip>
</q-btn>
</div>
<div v-else>
<q-spinner color="primary" size="2.55em"></q-spinner>
@@ -240,7 +236,7 @@
class="float-right"
>
<q-badge>
{% raw %}{{ extension.installedRelease.version }}{% endraw %}
<span v-text="extension.installedRelease.version"></span>
<q-tooltip>
<span v-text="$t('extension_installed_version')"></span>
</q-tooltip>
@@ -252,10 +248,10 @@
</div>
<q-dialog v-model="showUninstallDialog">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p>
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br />
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
<span v-text="$t('extension_uninstall_warning')"></span><br />
<span v-text="$t('confirm_continue')"></span>
</p>
<div class="row q-mt-lg">
@@ -264,32 +260,39 @@
value="false"
label="Cleanup database tables"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
><span v-text="$t('extension_db_drop_info')"></span>
</q-tooltip>
</q-checkbox>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="uninstallExtension()"
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
>
<q-btn
outline
color="grey"
@click="uninstallExtension()"
v-text="$t('uninstall_confirm')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showDropDbDialog">
<q-card v-if="selectedExtension" class="q-pa-lg">
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
<q-input
v-model="dropDbExtensionId"
:label="selectedExtension.id"
></q-input>
<br />
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
<p v-text="$t('confirm_continue')"></p>
<div class="row q-mt-lg">
<q-btn
@@ -297,17 +300,58 @@
outline
color="red"
@click="dropExtensionDb()"
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
>
v-text="$t('confirm')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showUpgradeDialog">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<a :href="'lightning:' + selectedRelease.paymentRequest">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div v-else>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</q-card-section>
<div class="row q-mt-lg">
<div class="col">
<q-btn
v-if="selectedRelease.paymentRequest"
outline
color="grey"
@click="copyText(selectedRelease.paymentRequest)"
:label="$t('copy_invoice')"
></q-btn>
</div>
<div class="col">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="selectedExtension?.name"></div>
</q-card-section>
@@ -337,7 +381,7 @@
<q-item-section>
<div class="row">
<div class="col-10">
{%raw%}{{ $t('repository') }}{%endraw%}
<span v-text="$t('repository')"></span>
<br />
<small v-text="repoName"></small>
<q-tooltip
@@ -376,19 +420,104 @@
color="pink"
size="70px"
></q-icon>
Cannot get the release details.
<span v-text="$t('release_details_error')"></span>
</div>
<q-card v-else>
<q-card-section v-if="release.is_version_compatible">
<q-btn
v-if="!release.isInstalled"
@click="installExtension(release)"
color="primary unelevated mt-lg pt-lg"
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
>
<q-btn v-else @click="showUninstall()" flat color="red">
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
<span
v-if="release.requiresPayment && !release.paid_sats"
v-text="$t('extension_cost', {cost: release.cost_sats})"
class="q-mb-lg"
></span>
<span
v-if="release.requiresPayment && release.paid_sats"
class="q-mb-lg"
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
></span>
<div
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
>
<q-btn
v-if="!release.isInstalled"
@click="installExtension(release)"
color="primary unelevated mt-lg pt-lg"
:label="$t('install')"
></q-btn>
</div>
<div v-if="release.requiresPayment && !release.paid_sats">
<div v-if="!release.payment_hash">
<q-input
filled
dense
v-model.number="release.paidAmount"
type="number"
:min="release.cost_sats"
suffix="sat"
class="q-mt-sm"
>
</q-input>
<q-select
filled
dense
emit-value
v-model="release.wallet"
:options="g.user.walletOptions"
label="Wallet *"
class="q-mt-sm"
>
</q-select>
<q-btn
unelevated
color="primary"
@click="payAndInstall(release)"
:disabled="!release.wallet"
class="q-mt-sm"
:label="$t('pay_from_wallet')"
></q-btn>
<q-btn
unelevated
color="primary"
@click="showQRCode(release)"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
</div>
<div v-else>
<br />
<span
class="q-mb-lg q-mt-lg"
v-text="'There is a previous pending invoice for this release.'"
></span>
<br />
<q-btn
unelevated
@click="installExtension(release)"
color="primary"
class="q-mt-sm"
:label="$t('retry_install')"
></q-btn>
<q-btn
unelevated
@click="clearHangingInvoice(release)"
color="primary"
class="q-mt-sm float-right"
:label="$t('new_payment')"
></q-btn>
</div>
</div>
<div>
<q-btn
v-if="release.isInstalled"
@click="showUninstall()"
:label="$t('uninstall')"
flat
color="red"
></q-btn>
</div>
<a
v-if="release.html_url"
class="text-secondary float-right"
@@ -396,11 +525,11 @@
target="_blank"
rel="noopener noreferrer"
style="color: inherit"
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a
>
v-text="$t('release_notes')"
></a>
</q-card-section>
<q-card-section v-else>
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%}
<span v-text="$t('extension_min_lnbits_version')"></span>
<strong>
<span v-text="release.min_lnbits_version"></span>
</strong>
@@ -408,8 +537,11 @@
<q-card v-if="release.warning">
<q-card-section>
<div class="text-h6">
<q-badge color="yellow" text-color="black">
{%raw%}{{ $t('warning') }}{%endraw%}
<q-badge
color="yellow"
text-color="black"
v-text="$t('warning')"
>
</q-badge>
</div>
<div class="text-subtitle2">
@@ -432,9 +564,8 @@
@click="showUninstall()"
flat
color="red"
>
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
>
v-text="$t('uninstall')"
></q-btn>
<q-btn
v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()"
@@ -442,9 +573,13 @@
color="red"
:label="$t('drop_db')"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
@@ -464,8 +599,10 @@
dropDbExtensionId: '',
selectedExtension: null,
selectedExtensionRepos: null,
selectedRelease: null,
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
user: null
}
},
@@ -504,10 +641,18 @@
.filter(extensionNameContains(term))
this.tab = tab
},
installExtension: async function (release) {
// no longer required to check if the invoice was paid
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const extension = this.selectedExtension
extension.inProgress = true
this.showUpgradeDialog = false
release.payment_hash =
release.payment_hash || this.getPaylinkHash(release.pay_link)
LNbits.api
.request(
'POST',
@@ -516,7 +661,9 @@
{
ext_id: extension.id,
archive: release.archive,
source_repo: release.source_repo
source_repo: release.source_repo,
payment_hash: release.payment_hash,
version: release.version
}
)
.then(response => {
@@ -530,8 +677,9 @@
this.tab = 'installed'
})
.catch(err => {
LNbits.utils.notifyApiError(err)
console.warn(err)
extension.inProgress = false
LNbits.utils.notifyApiError(err)
})
},
uninstallExtension: async function () {
@@ -623,8 +771,10 @@
showUpgrade: async function (extension) {
this.selectedExtension = extension
this.showUpgradeDialog = true
this.selectedRelease = null
this.selectedExtensionRepos = null
this.showUpgradeDialog = true
try {
const {data} = await LNbits.api.request(
'GET',
@@ -648,6 +798,12 @@
if (release.isInstalled) {
repos[release.source_repo].isInstalled = true
}
if (release.pay_link) {
release.requiresPayment = true
release.paidAmount = release.cost_sats
release.payment_hash = this.getPaylinkHash(release.pay_link)
}
repos[release.source_repo].releases.push(release)
return repos
}, {})
@@ -656,6 +812,122 @@
extension.inProgress = false
}
},
async payAndInstall(release) {
try {
this.selectedExtension.inProgress = true
this.showUpgradeDialog = false
const paymentInfo = await this.requestPayment(
this.selectedExtension.id,
release
)
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
const {data} = await LNbits.api.payInvoice(
wallet,
paymentInfo.payment_request
)
release.payment_hash = data.payment_hash
await this.installExtension(release)
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
} finally {
this.selectedExtension.inProgress = false
}
},
async showQRCode(release) {
this.selectedRelease = release
try {
const data = await this.requestPayment(
this.selectedExtension.id,
release
)
this.selectedRelease.paymentRequest = data.payment_request
this.selectedRelease.payment_hash = data.payment_hash
this.selectedRelease = _.clone(this.selectedRelease)
this.rememberPaylinkHash(
this.selectedRelease.pay_link,
this.selectedRelease.payment_hash
)
this.subscribeToPaylinkWs(
this.selectedRelease.pay_link,
data.payment_hash
)
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
}
},
async requestPayment(extId, release) {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/invoice`,
this.g.user.wallets[0].adminkey,
{
ext_id: extId,
archive: release.archive,
source_repo: release.source_repo,
cost_sats: release.paidAmount,
version: release.version
}
)
return data
},
clearHangingInvoice(release) {
this.forgetPaylinkHash(release.pay_link)
release.payment_hash = null
},
rememberPaylinkHash(pay_link, payment_hash) {
this.$q.localStorage.set(
`lnbits.extensions.paylink.${pay_link}`,
payment_hash
)
},
getPaylinkHash(pay_link) {
return this.$q.localStorage.getItem(
`lnbits.extensions.paylink.${pay_link}`
)
},
forgetPaylinkHash(pay_link) {
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
},
subscribeToPaylinkWs(pay_link, payment_hash) {
const url = new URL(`${pay_link}/${payment_hash}`)
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
this.paylinkWebsocket = new WebSocket(url)
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
const resp = JSON.parse(data)
if (resp.paid) {
this.$q.notify({
type: 'positive',
message: 'Invoice Paid!'
})
this.installExtension(this.selectedRelease)
} else {
this.$q.notify({
type: 'warning',
message: 'Invoice tracking lost!'
})
}
})
},
unsubscribeFromPaylinkWs() {
try {
this.paylinkWebsocket && this.paylinkWebsocket.close()
} catch (error) {
console.warn(error)
}
},
hasNewVersion: function (extension) {
if (extension.installedRelease && extension.latestRelease) {
return (
+56 -14
View File
@@ -8,12 +8,19 @@
></div>
<div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
<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}}
</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}}
</h5>
<div
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div>
</div>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
LNBITS_AUTH_METHODS)%}
@@ -306,7 +313,7 @@
</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"
>
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
@@ -331,7 +338,7 @@
outline
color="grey"
type="a"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
@@ -490,25 +497,60 @@
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a href="https://zbd.gg" target="_blank" rel="noopener noreferrer">
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
</div>
<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 q-pl-md"></div>
</div>
</div>
</div>
</div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_SPACE_TITLE }}</q-btn
>
</div>
{% if AD_SPACE %}
<div class="row justify-center">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_SPACE_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md">
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" />
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column">
<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>
</div>
{% endfor %} {% endif %}
{% endfor %}
</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-10 q-pl-xl">
<span v-text="$t('lnbits_description')"></span>
+20 -249
View File
@@ -102,226 +102,11 @@
</div>
</div>
</q-card>
<q-card
:style="$q.screen.lt.md ? {
background: $q.screen.lt.md ? 'none !important': ''
, boxShadow: $q.screen.lt.md ? 'none !important': ''
, marginTop: $q.screen.lt.md ? '0px !important': ''
} : ''"
>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn
flat
color="grey"
@click="exportCSV"
:label="$t('export_csv')"
></q-btn>
<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>
<payment-list
:update="updatePayments"
:wallet="this.g.wallet"
:mobile-simple="mobileSimple"
/>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
@@ -370,7 +155,7 @@
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode
:value="'{{request.base_url}}' +'wallet?wal={{wallet.id}}'"
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</q-card-section>
@@ -459,19 +244,21 @@
</q-list>
</q-card-section>
</q-card>
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(";") %}
{% endif %} {% if AD_SPACE %}
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ AD_SPACE_TITLE }}
</h6>
</q-card-section>
{% for ADS in AD_SPACE %} {% set AD = ADS.split(";") %}
<q-card-section class="q-pa-none">
<a
style="display: inline-block"
href="{{ AD[0] }}"
class="q-ma-md"
class="q-ml-md q-mb-xs q-mr-md"
style="max-width: 80%"
>
<img
style="max-width: 100%; height: auto"
@@ -483,8 +270,10 @@
v-else
src="{{ AD[2] }}"
/>
</a> </q-card-section></q-card
>{% endfor %} {% endif %}
</a> </q-card-section
>{% endfor %}
</q-card>
{% endif %}
</div>
</div>
@@ -873,27 +662,6 @@
</q-card>
</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
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
@@ -922,8 +690,11 @@
<q-dialog v-model="disclaimerDialog.show" position="top">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p v-text="$t('disclaimer_dialog')"></p>
<h6
class="q-my-md text-primary"
v-text="$t('disclaimer_dialog_title')"
></h6>
<p class="whitespace-pre-line" v-text="$t('disclaimer_dialog')"></p>
<div class="row q-mt-lg">
<q-btn
outline
+15 -19
View File
@@ -150,7 +150,6 @@
Open channel
</q-btn>
</div>
{% raw %}
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
@@ -172,11 +171,12 @@
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1 col-grow">
{{props.row.name}}
</div>
<div
class="text-subtitle1 col-grow"
v-text="props.row.name"
></div>
<div class="text-caption" v-if="props.row.short_id">
{{ props.row.short_id }}
<span v-text="props.row.short_id"></span>
<q-btn
size="xs"
flat
@@ -188,9 +188,8 @@
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
>
{{ states.find(s => s.value == props.row.state)?.label
}}
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
@@ -210,15 +209,12 @@
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
</div>
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="column q-gutter-y-sm">
{% raw %}
<div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
>
@@ -254,19 +250,21 @@
<q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow">
<div class="text-subtitle1 text-bold">
{{ props.row.alias }}
</div>
<div
class="text-subtitle1 text-bold"
v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
v-text="'#'+props.row.color"
>
#{{ props.row.color }}
</q-badge>
<div class="text-bold">
{{ shortenNodeId(props.row.id) }}
</div>
<div
class="text-bold"
v-text="shortenNodeId(props.row.id)"
></div>
<q-btn
size="xs"
flat
@@ -302,8 +300,6 @@
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
</div>
@@ -1,6 +1,5 @@
<q-tab-panel name="dashboard">
<q-card-section class="q-pa-none">
{% raw %}
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
@@ -65,6 +64,5 @@
></lnbits-channel-stats>
</div>
</div>
{% endraw %}
</q-card-section>
</q-tab-panel>
@@ -3,7 +3,6 @@
<q-dialog v-model="transactionDetailsDialog.show">
<q-card class="my-card">
<q-card-section>
{% raw %}
<div class="text-center q-mb-lg">
<div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
@@ -18,7 +17,9 @@
<div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono">
{{ transactionDetailsDialog.data.payment_hash }}
<span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
@@ -33,7 +34,7 @@
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
{{ transactionDetailsDialog.data.preimage }}
<span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
@@ -66,7 +67,6 @@
></q-btn>
</div>
</div>
{% endraw %}
</q-card-section>
</q-card>
</q-dialog>
@@ -102,7 +102,6 @@
:filter="paymentsTable.filter"
@request="getPayments"
>
{% raw %}
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
@@ -211,9 +210,8 @@
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
>
{{ props.row.destination?.alias }}
</q-badge>
v-text="props.row.destination?.alias"
></q-badge>
<div>
<q-btn
size="xs"
@@ -233,7 +231,6 @@
</div>
</q-td>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
@@ -266,7 +263,6 @@
:filter="invoiceTable.filter"
@request="getInvoices"
>
{% raw %}
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
@@ -305,8 +301,6 @@
></lnbits-date>
</q-td>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
@@ -1,7 +1,6 @@
// update cache version every time there is a new deployment
// so the service worker reinitializes the cache
const CACHE_VERSION = 115
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
const getApiKey = request => {
let api_key = request.headers.get('X-Api-Key')
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
if (currentCacheVersion !== CACHE_VERSION) {
if (!cacheName.startsWith(CURRENT_CACHE)) {
return caches.delete(cacheName)
}
})
@@ -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: 600px">
<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
:data="users"
:row-key="usersTableRowKey"
: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' : ''"
@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 %}
+10 -48
View File
@@ -8,14 +8,11 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_wallet
from lnbits.core.models import CreateTopup, User
from lnbits.core.models import User
from lnbits.core.services import (
get_balance_delta,
update_cached_settings,
update_wallet_balance,
)
from lnbits.core.tasks import api_invoice_listeners
from lnbits.decorators import check_admin, check_super_user
@@ -26,32 +23,21 @@ from lnbits.tasks import invoice_listeners
from .. import core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
admin_router = APIRouter()
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
@admin_router.get(
"/admin/api/v1/audit",
"/api/v1/audit",
name="Audit",
description="show the current balance of the node and the LNbits database",
dependencies=[Depends(check_admin)],
)
async def api_auditor():
try:
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.",
)
return await get_balance_delta()
@admin_router.get(
"/admin/api/v1/monitor",
"/api/v1/monitor",
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
@@ -63,7 +49,7 @@ async def api_monitor():
}
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
) -> Optional[AdminSettings]:
@@ -72,7 +58,7 @@ async def api_get_settings(
@admin_router.put(
"/admin/api/v1/settings/",
"/api/v1/settings",
status_code=HTTPStatus.OK,
)
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
@@ -85,7 +71,7 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
@admin_router.delete(
"/admin/api/v1/settings/",
"/api/v1/settings",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
@@ -95,7 +81,7 @@ async def api_delete_settings() -> None:
@admin_router.get(
"/admin/api/v1/restart/",
"/api/v1/restart",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
@@ -104,32 +90,8 @@ async def api_restart_server() -> dict[str, str]:
return {"status": "Success"}
@admin_router.put(
"/admin/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/api/v1/backup/",
"/api/v1/backup",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
response_class=FileResponse,
+28 -741
View File
@@ -1,111 +1,52 @@
import asyncio
import hashlib
import json
import uuid
from http import HTTPStatus
from io import BytesIO
from math import ceil
from typing import Dict, List, Optional, Union
from typing import Dict, List
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import (
APIRouter,
Body,
Depends,
Header,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from sse_starlette.sse import EventSourceResponse
from starlette.responses import StreamingResponse
from lnbits import bolt11
from lnbits.core.db import core_app_extra, db
from lnbits.core.helpers import (
migrate_extension_database,
stop_extension_background_work,
)
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateInvoice,
CreateLnurl,
CreateLnurlAuth,
CreateWallet,
DecodePayment,
Payment,
PaymentFilters,
PaymentHistoryPoint,
Query,
User,
Wallet,
WalletType,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_access_token,
check_admin,
get_key_type,
parse_filters,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
fetch_github_release_config,
get_valid_extensions,
)
from lnbits.helpers import generate_filter_params_openapi, url_for
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
currencies,
allowed_currencies,
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from ..crud import (
DateTrunc,
add_installed_extension,
create_account,
create_wallet,
delete_dbversion,
delete_installed_extension,
delete_wallet,
drop_extension_db,
get_dbversions,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
save_balance_check,
update_pending_payments,
update_wallet,
)
from ..services import (
InvoiceFailure,
PaymentFailure,
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
perform_lnurlauth,
websocketManager,
websocketUpdater,
)
from ..tasks import api_invoice_listeners
from ..services import perform_lnurlauth
api_router = APIRouter()
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
from .payment_api import api_payment, pay_invoice # noqa: F401
api_router = APIRouter(tags=["Core"])
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
@@ -113,465 +54,34 @@ async def health():
return
@api_router.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == WalletType.admin:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@api_router.put("/api/v1/wallet/{new_name}")
async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
await update_wallet(wallet.wallet.id, new_name)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
@api_router.patch("/api/v1/wallet", response_model=Wallet)
async def api_update_wallet(
name: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
return await update_wallet(wallet.wallet.id, name, currency)
@api_router.delete("/api/v1/wallet")
async def api_delete_wallet(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> None:
await delete_wallet(
user_id=wallet.wallet.user,
wallet_id=wallet.wallet.id,
)
@api_router.post("/api/v1/wallet", response_model=Wallet)
async def api_create_wallet(
data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
@api_router.get(
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
)
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
return [BaseWallet(**w.dict()) for w in user.wallets]
@api_router.post("/api/v1/account", response_model=Wallet)
async def api_create_account(data: CreateWallet) -> Wallet:
if not settings.new_accounts_allowed:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_account()
return await create_wallet(user_id=account.id, wallet_name=data.name)
@api_router.get(
"/api/v1/payments",
name="Payment List",
summary="get list of payments",
response_description="list of payments",
response_model=List[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
complete=True,
filters=filters,
)
@api_router.get(
"/api/v1/payments/history",
name="Get payments history",
response_model=List[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters)
@api_router.get(
"/api/v1/payments/paginated",
name="Payment List",
summary="get paginated list of payments",
response_description="list of payments",
response_model=Page[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
page = await get_payments_paginated(
wallet_id=wallet.wallet.id,
pending=True,
complete=True,
filters=filters,
)
return page
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
)
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
try:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
except InvoiceFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
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}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
"balanceNotify": url_for(
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
external=True,
wal=wallet.id,
),
},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"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,
}
@api_router.post(
"/api/v1/payments",
summary="Create or pay an invoice",
description="""
This endpoint can be used both to generate and pay a BOLT11 invoice.
To generate a new invoice for receiving funds into the authorized account,
specify at least the first four fields in the POST body: `out: false`,
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
already in the authorized account, specify `out: true` and use the `bolt11`
field to supply the BOLT11 invoice to be paid.
""",
status_code=HTTPStatus.CREATED,
)
async def api_payments_create(
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoiceData: CreateInvoice = Body(...),
):
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
if not invoiceData.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
)
return await api_payments_pay_invoice(
invoiceData.bolt11, wallet.wallet, invoiceData.extra
) # admin key
elif not invoiceData.out:
# invoice key
return await api_payments_create_invoice(invoiceData, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
@api_router.get("/api/v1/payments/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
if invoice_obj.amount_msat:
response = {
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
}
return JSONResponse(response)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invoice has no amount.",
)
@api_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
)
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
"""
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
Listenes invoming payments for a wallet and yields jsons with payment details.
"""
this_wallet_id = wallet.id
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
logger.debug(f"adding sse listener for wallet: {uid}")
api_invoice_listeners[uid] = payment_queue
try:
while True:
if await request.is_disconnected():
await request.close()
break
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
logger.debug("sse listener: payment received", payment)
yield dict(data=payment.json(), event="payment-received")
except asyncio.CancelledError:
logger.debug(f"removing listener for wallet {uid}")
except Exception as exc:
logger.error(f"Error in sse: {exc}")
finally:
api_invoice_listeners.pop(uid)
@api_router.get("/api/v1/payments/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet),
ping=20,
media_type="text/event-stream",
)
# TODO: refactor this route into a public and admin one
@api_router.get("/api/v1/payments/{payment_hash}")
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
# 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 = wallet if wallet and not wallet.deleted else None
# we have to specify the wallet id here, because postgres and sqlite return
# internal payments in different order and get_standalone_payment otherwise
# just fetches the first one, causing unpredictable results
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
try:
await payment.check_status()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
return {"paid": False}
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage}
@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:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception:
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
@@ -586,7 +96,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
)
) from exc
# params is what will be returned to the client
params: Dict = {"domain": domain}
@@ -611,14 +121,14 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError:
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
)
) from exc
try:
tag: str = data.get("tag")
@@ -677,28 +187,11 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
)
) from exc
return params
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = str(lnurl_decode(payment_str))
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data)
except Exception as exc:
return JSONResponse(
{"message": f"Failed to decode: {str(exc)}"},
status_code=HTTPStatus.BAD_REQUEST,
)
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
@@ -712,14 +205,8 @@ async def api_perform_lnurlauth(
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available():
if len(settings.lnbits_allowed_currencies) > 0:
return [
item
for item in currencies.keys()
if item.upper() in settings.lnbits_allowed_currencies
]
return list(currencies.keys())
async def api_list_currencies_available() -> List[str]:
return allowed_currencies()
@api_router.post("/api/v1/conversion")
@@ -759,203 +246,3 @@ async def img(data):
"Expires": "0",
},
)
@api_router.websocket("/api/v1/ws/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocketManager.connect(websocket, item_id)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
websocketManager.disconnect(websocket)
@api_router.post("/api/v1/ws/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
@api_router.get("/api/v1/ws/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
@api_router.post("/api/v1/extension")
async def api_install_extension(
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive
)
if not release:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
if not release.is_version_compatible:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
)
ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
)
ext_info.download_archive()
try:
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)
# 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.nofiy_upgrade()
return extension
except Exception as ex:
logger.warning(ex)
ext_info.clean_extension_files()
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
)
@api_router.delete("/api/v1/extension/{ext_id}")
async def api_uninstall_extension(
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]
if len(extensions) == 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown extension id: {ext_id}",
)
# check that other extensions do not depend on this one
for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())):
installed_ext = next(
(ext for ext in installable_extensions if ext.id == valid_ext_id), None
)
if installed_ext and ext_id in installed_ext.dependencies:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Cannot uninstall. Extension '{installed_ext.name}' "
"depends on this one."
),
)
try:
# call stop while the old routes are still active
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.")
except Exception as ex:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
)
@api_router.get(
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
)
async def get_extension_releases(ext_id: str):
try:
extension_releases: List[
ExtensionRelease
] = await InstallableExtension.get_extension_releases(ext_id)
return extension_releases
except Exception as ex:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
)
@api_router.get(
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],
)
async def get_extension_release(org: str, repo: str, tag_name: str):
try:
config = await fetch_github_release_config(org, repo, tag_name)
if not config:
return {}
return {
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
}
except Exception as ex:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
)
@api_router.delete(
"/api/v1/extension/{ext_id}/db",
dependencies=[Depends(check_admin)],
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown extension id: {ext_id}",
)
await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{ext_id}'")
except HTTPException as ex:
logger.error(ex)
raise ex
except Exception as ex:
logger.error(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'",
)
+64 -62
View File
@@ -44,15 +44,15 @@ from ..models import (
UserConfig,
)
auth_router = APIRouter()
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
@auth_router.get("", description="Get the authenticated user")
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
return user
@auth_router.post("/api/v1/auth", description="Login via the username and password")
@auth_router.post("", description="Login via the username and password")
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
@@ -68,14 +68,14 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
except HTTPException as e:
raise e
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/api/v1/auth/usr", description="Login via the User ID")
@auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
@@ -86,14 +86,14 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
except HTTPException as e:
raise e
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider")
@auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request, provider: str, user_id: Optional[str] = None
):
@@ -109,7 +109,7 @@ async def login_with_sso_provider(
return await provider_sso.get_login_redirect(state=state)
@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback")
@auth_router.get("/{provider}/token", description="Handle OAuth callback")
async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
provider_sso = _new_sso(provider)
if not provider_sso:
@@ -124,28 +124,30 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
except HTTPException as e:
raise e
except ValueError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
except HTTPException as exc:
raise exc
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.",
)
) from exc
@auth_router.post("/api/v1/auth/logout")
@auth_router.post("/logout")
async def logout() -> JSONResponse:
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("lnbits_last_active_wallet")
return response
@auth_router.post("/api/v1/auth/register")
@auth_router.post("/register")
async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
@@ -167,14 +169,16 @@ async def register(data: CreateUser) -> JSONResponse:
user = await create_user(data)
return _auth_success_response(user.username)
except ValueError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
) from exc
@auth_router.put("/api/v1/auth/password")
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists)
) -> Optional[User]:
@@ -187,16 +191,16 @@ async def update_password(
try:
return await update_user_password(data)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
)
) from exc
@auth_router.put("/api/v1/auth/update")
@auth_router.put("/update")
async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
@@ -209,14 +213,16 @@ async def update(
try:
return await update_account(user.id, data.username, None, data.config)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
) from exc
@auth_router.put("/api/v1/auth/first_install")
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
@@ -235,13 +241,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
await update_user_password(super_user)
settings.first_install = False
return _auth_success_response(username=super_user.username)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
)
) from exc
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
@@ -286,9 +292,7 @@ def _auth_success_response(
)
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie(
"is_lnbits_user_authorized", "true", samesite="none", secure=True
)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
return response
@@ -298,9 +302,7 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email})
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie(
"is_lnbits_user_authorized", "true", samesite="none", secure=True
)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
return response
@@ -318,16 +320,16 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
logger.warning(f"{provider} auth allowed but not configured.")
return None
SSOProviderClass = _find_auth_provider_class(provider)
ssoProvider = SSOProviderClass(
sso_provider_class = _find_auth_provider_class(provider)
sso_provider = sso_provider_class(
client_id, client_secret, None, allow_insecure_http=True
)
if (
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
return ssoProvider
sso_provider.discovery_url = discovery_url
return sso_provider
except Exception as e:
logger.warning(e)
@@ -339,9 +341,9 @@ def _find_auth_provider_class(provider: str) -> Callable:
for module in sso_modules:
try:
provider_module = importlib.import_module(f"{module}.{provider}")
ProviderClass = getattr(provider_module, f"{provider.title()}SSO")
if ProviderClass:
return ProviderClass
provider_class = getattr(provider_module, f"{provider.title()}SSO")
if provider_class:
return provider_class
except Exception:
pass
+272
View File
@@ -0,0 +1,272 @@
from http import HTTPStatus
from typing import (
List,
Optional,
)
from bolt11 import decode as bolt11_decode
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import (
migrate_extension_database,
stop_extension_background_work,
)
from lnbits.core.models import (
User,
)
from lnbits.decorators import (
check_access_token,
check_admin,
)
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
fetch_github_release_config,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.settings import settings
from ..crud import (
add_installed_extension,
delete_dbversion,
delete_installed_extension,
drop_extension_db,
get_dbversions,
get_installed_extension,
get_installed_extensions,
)
extension_router = APIRouter(
tags=["Extension Managment"],
prefix="/api/v1/extension",
)
@extension_router.post("")
async def api_install_extension(
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
if not release:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
if not release.is_version_compatible:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
)
release.payment_hash = data.payment_hash
ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
)
try:
installed_ext = await get_installed_extension(data.ext_id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info)
db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version)
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 exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
ext_info.clean_extension_files()
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
) from exc
@extension_router.delete("/{ext_id}")
async def api_uninstall_extension(
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
installed_extensions = await get_installed_extensions()
extensions = [e for e in installed_extensions if e.id == ext_id]
if len(extensions) == 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown extension id: {ext_id}",
)
# check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
)
if installed_ext and ext_id in installed_ext.dependencies:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Cannot uninstall. Extension '{installed_ext.name}' "
"depends on this one."
),
)
try:
# call stop while the old routes are still active
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.")
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str):
try:
extension_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
installed_ext = await get_installed_extension(ext_id)
if not installed_ext:
return extension_releases
for release in extension_releases:
payment_info = installed_ext.find_existing_payment(release.pay_link)
if payment_info:
release.paid_sats = payment_info.amount
return extension_releases
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@extension_router.put("/invoice", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension):
try:
assert data.cost_sats, "A non-zero amount must be specified"
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
assert release, "Release not found"
assert release.pay_link, "Pay link not found for release"
payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats
)
assert payment_info and payment_info.payment_request, "Cannot request invoice"
invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing"
invoice_amount = int(invoice.amount_msat / 1000)
assert (
invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}."
assert (
payment_info.payment_hash == invoice.payment_hash
), "Wroong invoice payment hash"
return payment_info
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(
"/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],
)
async def get_extension_release(org: str, repo: str, tag_name: str):
try:
config = await fetch_github_release_config(org, repo, tag_name)
if not config:
return {}
return {
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@extension_router.delete(
"/{ext_id}/db",
dependencies=[Depends(check_admin)],
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown extension id: {ext_id}",
)
await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{ext_id}'")
except HTTPException as ex:
logger.error(ex)
raise ex
except Exception as exc:
logger.error(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'",
) from exc
+84 -173
View File
@@ -1,40 +1,35 @@
import asyncio
import sys
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse
from fastapi import Cookie, Depends, Query, Request, status
from fastapi import Cookie, Depends, Query, Request
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 loguru import logger
from pydantic.types import UUID4
from lnbits.core.db import core_app_extra, db
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
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.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 currencies
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_account,
create_wallet,
get_balance_check,
get_dbversions,
get_inactive_extensions,
get_installed_extensions,
get_user,
save_balance_notify,
update_installed_extension_state,
update_user_extension,
)
from ..services import pay_invoice, redeem_lnurl_withdraw
generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False
@@ -49,7 +44,7 @@ async def favicon():
@generic_router.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning}
request, "core/index.html", {"lnurl": lightning}
)
@@ -57,15 +52,15 @@ async def home(request: Request, lightning: str = ""):
async def first_install(request: Request):
if not settings.first_install:
return template_renderer().TemplateResponse(
request,
"error.html",
{
"request": request,
"err": "Super user account has already been configured.",
},
)
return template_renderer().TemplateResponse(
request,
"core/first_install.html",
{"request": request},
)
@@ -113,6 +108,7 @@ async def extensions_install(
except Exception as ex:
logger.warning(ex)
installable_exts = []
installed_exts_ids = []
try:
ext_id = activate or deactivate
@@ -137,49 +133,49 @@ async def extensions_install(
ext_id=ext_id, active=activate is not None
)
all_ext_ids = list(map(lambda e: e.code, all_extensions))
all_ext_ids = [ext.code for ext in all_extensions]
inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions()
extensions = list(
map(
lambda ext: {
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
},
installable_exts,
)
)
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"request": request,
"user": user.dict(),
"extensions": extensions,
},
)
except Exception as e:
logger.warning(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@generic_router.get(
@@ -207,23 +203,22 @@ async def wallet(
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"}
request, "error.html", {"err": "Wallet not found"}
)
resp = template_renderer().TemplateResponse(
request,
"core/wallet.html",
{
"request": request,
"user": user.dict(),
"wallet": user_wallet.dict(),
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
},
)
resp.set_cookie(
"lnbits_last_active_wallet", wallet_id, samesite="none", secure=True
)
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
return resp
@@ -237,127 +232,25 @@ async def account(
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"request": request,
"user": user.dict(),
},
)
@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
)
@generic_router.get("/service-worker.js")
async def service_worker(request: Request):
return template_renderer().TemplateResponse(
request,
"service-worker.js",
{
"cache_version": settings.server_startup_time,
},
media_type="text/javascript",
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@generic_router.get("/service-worker.js", response_class=FileResponse)
async def service_worker():
return FileResponse(Path("lnbits", "static", "js", "service-worker.js"))
@generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str):
@@ -447,13 +340,13 @@ async def node(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
WALLET = get_wallet_class()
_, balance = await WALLET.status()
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"node/index.html",
{
"request": request,
"user": user.dict(),
"settings": settings.dict(),
"balance": balance,
@@ -467,13 +360,13 @@ async def node_public(request: Request):
if not settings.lnbits_public_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
WALLET = get_wallet_class()
_, balance = await WALLET.status()
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"node/public.html",
{
"request": request,
"settings": settings.dict(),
"balance": balance,
},
@@ -481,20 +374,36 @@ async def node_public(request: Request):
@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:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
WALLET = get_wallet_class()
_, balance = await WALLET.status()
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"admin/index.html",
{
"user": user.dict(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
},
)
@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(),
"balance": balance,
"currencies": list(currencies.keys()),
},
)
@@ -505,8 +414,10 @@ async def hex_to_uuid4(hex_value: str):
try:
user_id = to_valid_user_id(hex_value).hex
return RedirectResponse(url=f"/wallet?usr={user_id}")
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
+23 -11
View File
@@ -27,8 +27,8 @@ from ...utils.cache import cache
def require_node():
NODE = get_node_class()
if not NODE:
node_class = get_node_class()
if not node_class:
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API",
@@ -38,7 +38,7 @@ def require_node():
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
return NODE
return node_class
def check_public():
@@ -49,12 +49,20 @@ def check_public():
)
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
node_router = APIRouter(
tags=["Node Managment"],
prefix="/node/api/v1",
dependencies=[Depends(check_admin)],
)
super_node_router = APIRouter(
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
tags=["Node Managment"],
prefix="/node/api/v1",
dependencies=[Depends(check_super_user)],
)
public_node_router = APIRouter(
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
tags=["Node Managment"],
prefix="/node/public/api/v1",
dependencies=[Depends(check_public)],
)
@@ -108,9 +116,11 @@ async def api_delete_channel(
) -> Optional[List[NodeChannel]]:
return await node.close_channel(
short_id,
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
if funding_txid is not None and output_index is not None
else None,
(
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
if funding_txid is not None and output_index is not None
else None
),
force,
)
@@ -185,5 +195,7 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
try:
r.raise_for_status()
return r.json()["noderank"]
except httpx.HTTPStatusError:
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=404, detail="Node not found on 1ml.com"
) from exc
+449
View File
@@ -0,0 +1,449 @@
import asyncio
import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional, Union
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
Query,
Request,
)
from fastapi.responses import JSONResponse
from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
Payment,
PaymentFilters,
PaymentHistoryPoint,
Wallet,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from ..crud import (
DateTrunc,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_pending_payments,
)
from ..services import (
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
)
from ..tasks import api_invoice_listeners
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
@payment_router.get(
"",
name="Payment List",
summary="get list of payments",
response_description="list of payments",
response_model=List[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
complete=True,
filters=filters,
)
@payment_router.get(
"/history",
name="Get payments history",
response_model=List[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters)
@payment_router.get(
"/paginated",
name="Payment List",
summary="get paginated list of payments",
response_description="list of payments",
response_model=Page[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
page = await get_payments_paginated(
wallet_id=wallet.wallet.id,
pending=True,
complete=True,
filters=filters,
)
return page
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
@payment_router.post(
"",
summary="Create or pay an invoice",
description="""
This endpoint can be used both to generate and pay a BOLT11 invoice.
To generate a new invoice for receiving funds into the authorized account,
specify at least the first four fields in the POST body: `out: false`,
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
already in the authorized account, specify `out: true` and use the `bolt11`
field to supply the BOLT11 invoice to be paid.
""",
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(
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoice_data: CreateInvoice = Body(...),
):
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
)
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
elif not invoice_data.out:
# invoice key
return await api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
if invoice_obj.amount_msat:
response = {
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
}
return JSONResponse(response)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invoice has no amount.",
)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError) as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
"""
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
Listenes invoming payments for a wallet and yields jsons with payment details.
"""
this_wallet_id = wallet.id
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
logger.debug(f"adding sse listener for wallet: {uid}")
api_invoice_listeners[uid] = payment_queue
try:
while settings.lnbits_running:
if await request.is_disconnected():
await request.close()
break
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
logger.debug("sse listener: payment received", payment)
yield {"data": payment.json(), "event": "payment-received"}
except asyncio.CancelledError:
logger.debug(f"removing listener for wallet {uid}")
except Exception as exc:
logger.error(f"Error in sse: {exc}")
finally:
api_invoice_listeners.pop(uid)
@payment_router.get("/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet),
ping=20,
media_type="text/event-stream",
)
# TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}")
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
# 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
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
try:
status = await payment.check_status()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
return {"paid": False}
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"status": f"{status!s}",
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = str(lnurl_decode(payment_str))
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data)
except Exception as exc:
return JSONResponse(
{"message": f"Failed to decode: {exc!s}"},
status_code=HTTPStatus.BAD_REQUEST,
)
+4 -4
View File
@@ -9,7 +9,7 @@ from lnbits import bolt11
from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
public_router = APIRouter()
public_router = APIRouter(tags=["Core"])
@public_router.get("/public/v1/payment/{payment_hash}")
@@ -27,10 +27,10 @@ async def api_public_payment_longpolling(payment_hash):
invoice = bolt11.decode(payment.bolt11)
if invoice.has_expired():
return {"status": "expired"}
except Exception:
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
)
) from exc
payment_queue = asyncio.Queue(0)
@@ -50,7 +50,7 @@ async def api_public_payment_longpolling(payment_hash):
cancel_scope.cancel()
cancel_scope = asyncio.create_task(payment_info_receiver())
asyncio.create_task(timeouter(cancel_scope))
asyncio.create_task(timeouter(cancel_scope)) # noqa: RUF006
if response:
return response
+16 -15
View File
@@ -9,7 +9,8 @@ from starlette.responses import RedirectResponse
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from ..crud import (
@@ -19,7 +20,7 @@ from ..crud import (
get_tinyurl_by_url,
)
tinyurl_router = APIRouter()
tinyurl_router = APIRouter(tags=["Tinyurl"])
@tinyurl_router.post(
@@ -28,19 +29,19 @@ tinyurl_router = APIRouter()
description="creates a tinyurl",
)
async def api_create_tinyurl(
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
):
tinyurls = await get_tinyurl_by_url(url)
try:
for tinyurl in tinyurls:
if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey:
if tinyurl.wallet == wallet.wallet.id:
return tinyurl
return await create_tinyurl(url, endless, wallet.wallet.inkey)
except Exception:
return await create_tinyurl(url, endless, wallet.wallet.id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
)
) from exc
@tinyurl_router.get(
@@ -49,20 +50,20 @@ async def api_create_tinyurl(
description="get a tinyurl by id",
)
async def api_get_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey:
if tinyurl.wallet == wallet.wallet.id:
return tinyurl
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
)
except Exception:
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
)
) from exc
@tinyurl_router.delete(
@@ -71,21 +72,21 @@ async def api_get_tinyurl(
description="delete a tinyurl by id",
)
async def api_delete_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey:
if tinyurl.wallet == wallet.wallet.id:
await delete_tinyurl(tinyurl_id)
return {"deleted": True}
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
)
except Exception:
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
)
) from exc
@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
+77
View File
@@ -0,0 +1,77 @@
from typing import Optional
from fastapi import (
APIRouter,
Body,
Depends,
)
from lnbits.core.models import (
CreateWallet,
KeyType,
Wallet,
)
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
)
from ..crud import (
create_wallet,
delete_wallet,
update_wallet,
)
wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.key_type == KeyType.admin:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
await update_wallet(wallet.wallet.id, new_name)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
@wallet_router.patch("", response_model=Wallet)
async def api_update_wallet(
name: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
return await update_wallet(wallet.wallet.id, name, currency)
@wallet_router.delete("")
async def api_delete_wallet(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> None:
await delete_wallet(
user_id=wallet.wallet.user,
wallet_id=wallet.wallet.id,
)
@wallet_router.post("", response_model=Wallet)
async def api_create_wallet(
data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
+3 -3
View File
@@ -24,10 +24,10 @@ from ..crud import (
get_webpush_subscription,
)
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"])
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
@webpush_router.post("/", status_code=HTTPStatus.CREATED)
@webpush_router.post("", status_code=HTTPStatus.CREATED)
async def api_create_webpush_subscription(
request: Request,
data: CreateWebPushSubscription,
@@ -49,7 +49,7 @@ async def api_create_webpush_subscription(
)
@webpush_router.delete("/", status_code=HTTPStatus.OK)
@webpush_router.delete("", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription(
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
+42
View File
@@ -0,0 +1,42 @@
from fastapi import (
APIRouter,
WebSocket,
WebSocketDisconnect,
)
from lnbits.settings import settings
from ..services import (
websocket_manager,
websocket_updater,
)
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@websocket_router.websocket("/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocket_manager.connect(websocket, item_id)
try:
while settings.lnbits_running:
await websocket.receive_text()
except WebSocketDisconnect:
websocket_manager.disconnect(websocket)
@websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocket_updater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
@websocket_router.get("/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocket_updater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
+38 -23
View File
@@ -8,7 +8,7 @@ import time
from contextlib import asynccontextmanager
from enum import Enum
from sqlite3 import Row
from typing import Any, Generic, List, Literal, Optional, Type, TypeVar
from typing import Any, Generic, Literal, Optional, TypeVar
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -140,14 +140,14 @@ class Connection(Compat):
def rewrite_values(self, values):
# 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});")
# tuple to list and back to tuple
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
if isinstance(raw_value, str):
values.append(re.sub(CLEANR, "", raw_value))
values.append(re.sub(clean_regex, "", raw_value))
elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp()
if self.type == SQLITE:
@@ -175,20 +175,31 @@ class Connection(Compat):
async def fetch_page(
self,
query: str,
where: Optional[List[str]] = None,
values: Optional[List[str]] = None,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None,
model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
if not filters:
filters = Filters()
clause = filters.where(where)
parsed_values = filters.values(values)
group_by_string = ""
if group_by:
for field in group_by:
if not re.fullmatch(
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
):
raise ValueError("Value for GROUP BY is invalid")
group_by_string = f"GROUP BY {', '.join(group_by)}"
rows = await self.fetchall(
f"""
{query}
{clause}
{group_by_string}
{filters.order_by()}
{filters.pagination()}
""",
@@ -202,6 +213,7 @@ class Connection(Compat):
SELECT COUNT(*) FROM (
{query}
{clause}
{group_by_string}
) as count
""",
parsed_values,
@@ -242,7 +254,9 @@ class Database(Compat):
else:
self.schema = None
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
self.engine = create_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
)
self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}")
@@ -284,13 +298,14 @@ class Database(Compat):
async def fetch_page(
self,
query: str,
where: Optional[List[str]] = None,
values: Optional[List[str]] = None,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None,
model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model)
return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()):
async with self.connect() as conn:
@@ -355,8 +370,8 @@ class FromRowModel(BaseModel):
class FilterModel(BaseModel):
__search_fields__: List[str] = []
__sort_fields__: Optional[List[str]] = None
__search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None
T = TypeVar("T")
@@ -375,10 +390,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
op: Operator = Operator.EQ
values: list[Any]
model: Optional[Type[TFilterModel]]
model: Optional[type[TFilterModel]]
@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]):
# Key format:
# key[operator]
# e.g. name[eq]
@@ -428,7 +443,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
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
offset: Optional[int] = None
@@ -437,7 +452,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: Optional[str] = None
direction: Optional[Literal["asc", "desc"]] = None
model: Optional[Type[TFilterModel]] = None
model: Optional[type[TFilterModel]] = None
@root_validator(pre=True)
def validate_sortby(cls, values):
@@ -459,12 +474,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
stmt += f"OFFSET {self.offset}"
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:
where_stmts = []
if self.filters:
for filter in self.filters:
where_stmts.append(filter.statement)
for page_filter in self.filters:
where_stmts.append(page_filter.statement)
if self.search and self.model:
if DB_TYPE == POSTGRES:
where_stmts.append(
@@ -483,12 +498,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: Optional[List[str]] = None) -> tuple:
def values(self, values: Optional[list[str]] = None) -> tuple:
if not values:
values = []
if self.filters:
for filter in self.filters:
values.extend(filter.values)
for page_filter in self.filters:
values.extend(page_filter.values)
if self.search and self.model:
values.append(f"%{self.search}%")
return tuple(values)
+101 -187
View File
@@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional, Type, Union
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
@@ -17,101 +17,12 @@ from lnbits.core.crud import (
get_user,
get_wallet_for_key,
)
from lnbits.core.models import User, WalletType, WalletTypeInfo
from lnbits.core.models import KeyType, User, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
# TODO: fix type ignores
class KeyChecker(SecurityBase):
def __init__(
self,
scheme_name: Optional[str] = None,
auto_error: bool = True,
api_key: Optional[str] = None,
):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
self._key_type = WalletType.invoice
self._api_key = api_key
if api_key:
key = APIKey(
**{"in": APIKeyIn.query}, # type: ignore
name="X-API-KEY",
description="Wallet API Key - QUERY",
)
else:
key = APIKey(
**{"in": APIKeyIn.header}, # type: ignore
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.wallet = None
self.model: APIKey = key
async def __call__(self, request: Request):
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 or wallet.deleted:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid key or wallet.",
)
self.wallet = wallet # type: ignore
except KeyError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
)
class WalletInvoiceKeyChecker(KeyChecker):
"""
WalletInvoiceKeyChecker will ensure that the provided invoice
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,
@@ -124,109 +35,105 @@ api_key_query = APIKeyQuery(
)
class KeyChecker(SecurityBase):
def __init__(
self,
api_key: Optional[str] = None,
expected_key_type: Optional[KeyType] = None,
):
self.auto_error: bool = True
self.expected_key_type = expected_key_type
self._api_key = api_key
if api_key:
openapi_model = APIKey(
**{"in": APIKeyIn.query},
type=SecuritySchemeType.apiKey,
name="X-API-KEY",
description="Wallet API Key - QUERY",
)
else:
openapi_model = APIKey(
**{"in": APIKeyIn.header},
type=SecuritySchemeType.apiKey,
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.model: APIKey = openapi_model
async def __call__(self, request: Request) -> WalletTypeInfo:
key_value = (
self._api_key
if self._api_key
else request.headers.get("X-API-KEY") or request.query_params.get("api-key")
)
if not key_value:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No Api Key provided.",
)
wallet = await get_wallet_for_key(key_value)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid adminkey.",
)
if (
wallet.user != settings.super_user
and wallet.user not in settings.lnbits_admin_users
and settings.lnbits_admin_extensions
and request["path"].split("/")[1] in settings.lnbits_admin_extensions
):
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="User not authorized for this extension.",
)
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
return WalletTypeInfo(key_type, wallet)
async def get_key_type(
r: Request,
request: 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(
[WalletType.admin, WalletType.invoice],
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
):
try:
checker = WalletChecker(api_key=token)
await checker.__call__(r)
if checker.wallet is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
wallet = WalletTypeInfo(wallet_type, checker.wallet)
if (
wallet.wallet.user != settings.super_user
and wallet.wallet.user not in settings.lnbits_admin_users
) and (
settings.lnbits_admin_extensions
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
):
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="User not authorized for this extension.",
)
return 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."
)
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
return await check(request)
async def require_admin_key(
r: Request,
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
):
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
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
) -> WalletTypeInfo:
check: KeyChecker = KeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.admin,
)
return await check(request)
async def require_invoice_key(
r: Request,
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
):
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
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
) -> WalletTypeInfo:
check: KeyChecker = KeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.invoice,
)
return await check(request)
async def check_access_token(
@@ -254,8 +161,15 @@ async def check_user_exists(
user = await get_user(account.id)
assert user, "User not found for account."
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
if (
user.id != settings.super_user
and user.id not in settings.lnbits_admin_users
and settings.lnbits_admin_extensions
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "User not authorized for extension."
)
return user
@@ -323,10 +237,10 @@ async def _get_account_from_token(access_token):
return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError:
except ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
)
except JWTError as e:
logger.debug(e)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
) from exc
except JWTError as exc:
logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
+101
View File
@@ -0,0 +1,101 @@
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 lnbits.core.services import InvoiceError, PaymentError
from .helpers import template_renderer
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
register_http_exception_handler(app)
register_payment_error_handler(app)
register_invoice_error_handler(app)
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
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}
)
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"PaymentError: {exc.message}, {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
+132 -34
View File
@@ -1,16 +1,15 @@
import asyncio
import hashlib
import json
import os
import shutil
import sys
import zipfile
from http import HTTPStatus
from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple
from urllib import request
import httpx
from fastapi import HTTPException
from loguru import logger
from packaging import version
from pydantic import BaseModel
@@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str]
info_notification: Optional[str]
critical_notification: Optional[str]
pay_link: Optional[str]
def is_version_compatible(self):
if not self.min_lnbits_version:
@@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None
pay_link: Optional[str] = None
payment_hash: Optional[str] = None
payment_request: Optional[str] = None
def download_url(url, save_path):
with request.urlopen(url) as dl_file:
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
@@ -155,6 +162,21 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
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 ""
@@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
repo: Optional[str] = None
icon: Optional[str] = None
pay_link: Optional[str] = None
cost_sats: Optional[int] = None
paid_sats: Optional[int] = 0
payment_hash: Optional[str] = None
@property
def archive_url(self) -> str:
if not self.pay_link:
return self.archive
return (
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
)
async def check_payment_requirements(self):
if not self.pay_link:
return
payment_info = await fetch_release_payment_info(self.pay_link)
self.cost_sats = payment_info.amount if payment_info else None
@classmethod
def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease"
@@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
pay_link=e.pay_link,
repo=e.repo,
icon=e.icon,
)
@classmethod
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
try:
github_releases = await fetch_github_releases(org, repo)
return [
@@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
archive: Optional[str] = None
@property
@@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
return self.installed_release.version
return ""
def download_archive(self):
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
assert self.installed_release, "installed_release is none."
download_url(self.installed_release.archive, ext_zip_file)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Cannot fetch extension archive file",
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
except Exception as exc:
logger.warning(exc)
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash:
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="File hash missmatch. Will not install.",
)
raise AssertionError("File hash missmatch. Will not install.")
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
@@ -433,7 +479,7 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def nofiy_upgrade(self) -> None:
def notify_upgrade(self) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
@@ -445,8 +491,9 @@ class InstallableExtension(BaseModel):
settings.lnbits_upgraded_extensions,
)
)
settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
f"{self.hash}/{self.id}"
settings.lnbits_upgraded_extensions = [
*clean_upgraded_exts,
f"{self.hash}/{self.id}",
]
def clean_extension_files(self):
@@ -468,14 +515,58 @@ class InstallableExtension(BaseModel):
if version_parse(self.latest_release.version) < version_parse(release.version):
self.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link:
return None
return next(
(p for p in self.payments if p.pay_link == pay_link),
None,
)
def _restore_payment_info(self):
if not self.installed_release:
return
if not self.installed_release.pay_link:
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
if payment_info:
self.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if not self.installed_release or not self.installed_release.pay_link:
return
payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats,
pay_link=self.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash,
)
self.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link
]
self.payments.append(payment_info)
@classmethod
def from_row(cls, data: dict) -> "InstallableExtension":
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
def from_rows(
cls, rows: Optional[List[Any]] = None
) -> List["InstallableExtension"]:
if rows is None:
rows = []
return [InstallableExtension.from_row(row) for row in rows]
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
@@ -553,7 +644,7 @@ class InstallableExtension(BaseModel):
extension_list += [ext]
extension_id_list += [e.id]
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
@@ -565,34 +656,38 @@ class InstallableExtension(BaseModel):
try:
manifest = await fetch_manifest(url)
for r in manifest.repos:
if r.id == ext_id:
repo_releases = await ExtensionRelease.all_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
if r.id != ext_id:
continue
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
for e in manifest.extensions:
if e.id == ext_id:
extension_releases += [
ExtensionRelease.from_explicit_release(url, e)
]
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
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
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]:
all_releases: List[
ExtensionRelease
] = await InstallableExtension.get_extension_releases(ext_id)
all_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
r
for r in all_releases
if r.archive == archive and r.source_repo == source_repo
if r.archive == archive
and r.source_repo == source_repo
and r.version == version
]
return selected_release[0] if len(selected_release) != 0 else None
@@ -602,6 +697,9 @@ class CreateExtension(BaseModel):
ext_id: str
archive: str
source_repo: str
version: str
cost_sats: Optional[int] = 0
payment_hash: Optional[str] = None
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
+25
View File
@@ -20,6 +20,19 @@ 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:
return shortuuid.uuid()
@@ -58,12 +71,20 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
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_QR_LOGO"] = settings.lnbits_qr_logo
t.env.globals["LNBITS_VERSION"] = settings.version
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
settings.lnbits_extensions_deactivate_all
)
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
@@ -92,6 +113,10 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
def get_current_extension_name() -> str:
"""
DEPRECATED: Use the repo name instead, will be removed in the future
before: `register_invoice_listener(invoice_queue, get_current_extension_name())`
after: `register_invoice_listener(invoice_queue, "my-extension")`
Returns the name of the extension that calls this method.
"""
import inspect
+6 -6
View File
@@ -29,13 +29,13 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
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", [])
# block path for all users if the extension is disabled
if top_path in settings.lnbits_deactivated_extensions:
response = self._response_by_accepted_type(
headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
scope, headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
)
await response(scope, receive, send)
return
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
def _response_by_accepted_type(
self, headers: List[Any], msg: str, status_code: HTTPStatus
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus
) -> Union[HTMLResponse, JSONResponse]:
"""
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
@@ -78,11 +78,11 @@ class InstalledExtensionMiddleware:
"",
)
if "text/html" in [a for a in accept_header.split(",")]:
if "text/html" in accept_header.split(","):
return HTMLResponse(
status_code=status_code,
content=template_renderer()
.TemplateResponse("error.html", {"request": {}, "err": msg})
.TemplateResponse(Request(scope), "error.html", {"err": msg})
.body,
)
@@ -179,7 +179,7 @@ class ExtensionsRedirectMiddleware:
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [
e for e in ([redirect["ext_id"]] + redirect_to + req_tail_path) if e != ""
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
]
return "/" + "/".join(elements)
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Optional
from pydantic import BaseModel
@@ -212,7 +212,7 @@ class Node(ABC):
pass
@abstractmethod
async def get_channels(self) -> List[NodeChannel]:
async def get_channels(self) -> list[NodeChannel]:
pass
@abstractmethod
+40 -33
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
@@ -44,11 +44,12 @@ def catch_rpc_errors(f):
async def wrapper(*args, **kwargs):
try:
return await f(*args, **kwargs)
except RpcError as e:
if e.error["code"] == -32602:
raise HTTPException(status_code=400, detail=e.error["message"])
except RpcError as exc:
msg = exc.error["message"]
if exc.error["code"] == -32602:
raise HTTPException(status_code=400, detail=msg) from exc
else:
raise HTTPException(status_code=500, detail=e.error["message"])
raise HTTPException(status_code=500, detail=msg) from exc
return wrapper
@@ -66,9 +67,11 @@ class CoreLightningNode(Node):
# https://docs.corelightning.org/reference/lightning-connect
try:
await self.ln_rpc("connect", uri)
except RpcError as e:
if e.error["code"] == 400:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"])
except RpcError as exc:
if exc.error["code"] == 400:
raise HTTPException(
HTTPStatus.BAD_REQUEST, detail=exc.error["message"]
) from exc
else:
raise
@@ -76,12 +79,12 @@ class CoreLightningNode(Node):
async def disconnect_peer(self, peer_id: str):
try:
await self.ln_rpc("disconnect", peer_id)
except RpcError as e:
if e.error["code"] == -1:
except RpcError as exc:
if exc.error["code"] == -1:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
detail=e.error["message"],
)
detail=exc.error["message"],
) from exc
else:
raise
@@ -105,14 +108,14 @@ class CoreLightningNode(Node):
funding_txid=result["txid"],
output_index=result["outnum"],
)
except RpcError as e:
message = e.error["message"]
except RpcError as exc:
message = exc.error["message"]
if "amount: should be a satoshi amount" in message:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
detail="The amount is not a valid satoshi amount.",
)
) from exc
if "Unknown peer" in message:
raise HTTPException(
@@ -121,7 +124,7 @@ class CoreLightningNode(Node):
"We where able to connect to the peer but CLN "
"can't find it when opening a channel."
),
)
) from exc
if "Owning subdaemon openingd died" in message:
# 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 "
"proposal and disconnected from us."
),
)
) from exc
if (
"Number of pending channels exceed maximum" in message
or "exceeds maximum chan size of 10 BTC" 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
@catch_rpc_errors
@@ -152,13 +155,13 @@ class CoreLightningNode(Node):
raise HTTPException(status_code=400, detail="Short id required")
try:
await self.ln_rpc("close", short_id)
except RpcError as e:
message = e.error["message"]
except RpcError as exc:
message = exc.error["message"]
if (
"Short channel ID not active:" 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:
raise
@@ -168,7 +171,7 @@ class CoreLightningNode(Node):
return info["id"]
@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")
return [p["id"] for p in peers["peers"] if p["connected"]]
@@ -194,7 +197,7 @@ class CoreLightningNode(Node):
return NodePeerInfo(id=node["nodeid"])
@catch_rpc_errors
async def get_channels(self) -> List[NodeChannel]:
async def get_channels(self) -> list[NodeChannel]:
funds = await self.ln_rpc("listfunds")
nodes = await self.ln_rpc("listnodes")
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
@@ -217,17 +220,21 @@ class CoreLightningNode(Node):
state=(
ChannelState.ACTIVE
if ch["state"] == "CHANNELD_NORMAL"
else ChannelState.PENDING
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
else ChannelState.CLOSED
if ch["state"]
in (
"CHANNELD_CLOSING",
"CLOSINGD_COMPLETE",
"CLOSINGD_SIGEXCHANGE",
"ONCHAIN",
else (
ChannelState.PENDING
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
else (
ChannelState.CLOSED
if ch["state"]
in (
"CHANNELD_CLOSING",
"CLOSINGD_COMPLETE",
"CLOSINGD_SIGEXCHANGE",
"ONCHAIN",
)
else ChannelState.INACTIVE
)
)
else ChannelState.INACTIVE
),
)
for ch in funds["channels"]
+27 -19
View File
@@ -4,7 +4,7 @@ import asyncio
import base64
import json
from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
from httpx import HTTPStatusError
@@ -60,11 +60,13 @@ class LndRestNode(Node):
)
try:
response.raise_for_status()
except HTTPStatusError as e:
json = e.response.json()
except HTTPStatusError as exc:
json = exc.response.json()
if 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()
def get(self, path: str, **kwargs):
@@ -81,8 +83,8 @@ class LndRestNode(Node):
async def connect_peer(self, uri: str):
try:
pubkey, host = uri.split("@")
except ValueError:
raise HTTPException(400, detail="Invalid peer URI")
except ValueError as exc:
raise HTTPException(400, detail="Invalid peer URI") from exc
await self.request(
"POST",
"/v1/peers",
@@ -96,11 +98,11 @@ class LndRestNode(Node):
async def disconnect_peer(self, peer_id: str):
try:
await self.request("DELETE", "/v1/peers/" + peer_id)
except HTTPException as e:
if "unable to disconnect" in e.detail:
except HTTPException as exc:
if "unable to disconnect" in exc.detail:
raise HTTPException(
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
)
) from exc
raise
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
@@ -167,14 +169,16 @@ class LndRestNode(Node):
point: Optional[ChannelPoint] = None,
force: bool = False,
):
if short_id:
logger.debug(f"Closing channel with short_id: {short_id}")
if not point:
raise HTTPException(
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(
self.get("/v1/channels"),
self.get("/v1/channels/pending"),
@@ -237,9 +241,11 @@ class LndRestNode(Node):
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
state=ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE,
state=(
ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE
),
# name=channel['peer_alias'],
name=info.alias,
color=info.color,
@@ -318,11 +324,13 @@ class LndRestNode(Node):
amount=payment["value_msat"],
fee=payment["fee_msat"],
time=payment["creation_date"],
destination=await self.get_peer_info(
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
)
if payment["htlcs"]
else None,
destination=(
await self.get_peer_info(
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
)
if payment["htlcs"]
else None
),
bolt11=payment["payment_request"],
preimage=payment["payment_preimage"],
)
+1
View File
@@ -0,0 +1 @@
# Marker file for PEP 561
+9 -22
View File
@@ -10,10 +10,10 @@ from lnbits.settings import set_cli_settings, settings
@click.command(
context_settings=dict(
ignore_unknown_options=True,
allow_extra_args=True,
)
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
}
)
@click.option("--port", default=settings.port, help="Port to listen on")
@click.option("--host", default=settings.host, help="Host to run LNBits on")
@@ -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-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(
ctx,
port: int,
host: str,
forwarded_allow_ips: str,
ssl_keyfile: str,
ssl_certfile: str,
reload: bool,
):
"""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)
# this beautiful beast parses all command line arguments and
# passes them to the uvicorn server
d = dict()
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:
config = uvicorn.Config(
"lnbits.__main__:app",
@@ -70,7 +57,7 @@ def main(
forwarded_allow_ips=forwarded_allow_ips,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
**d
reload=reload or False,
)
server = uvicorn.Server(config=config)
+73 -34
View File
@@ -9,7 +9,7 @@ from hashlib import sha256
from os import path
from sqlite3 import Row
from time import time
from typing import Any, List, Optional
from typing import Any, Optional
import httpx
from loguru import logger
@@ -36,8 +36,8 @@ class LNbitsSettings(BaseModel):
class UsersSettings(LNbitsSettings):
lnbits_admin_users: List[str] = Field(default=[])
lnbits_allowed_users: List[str] = Field(default=[])
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
@property
@@ -46,8 +46,9 @@ class UsersSettings(LNbitsSettings):
class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: List[str] = Field(default=[])
lnbits_extensions_manifests: List[str] = Field(
lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_manifests: list[str] = Field(
default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
]
@@ -55,26 +56,41 @@ class ExtensionsSettings(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
lnbits_ext_github_token: str = Field(default="")
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: List[str] = Field(default=[])
lnbits_deactivated_extensions: list[str] = Field(default=[])
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: List[str] = Field(default=[])
lnbits_upgraded_extensions: list[str] = Field(default=[])
# list of redirects that extensions want to perform
lnbits_extensions_redirects: List[Any] = Field(default=[])
lnbits_extensions_redirects: list[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
None,
)
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
path = settings.extension_upgrade_path(ext_id)
return path.split("/")[0] if path else None
class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
lnbits_site_description: str = Field(default=None)
lnbits_site_description: 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_theme_options: List[str] = Field(
lnbits_custom_badge: str = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
default=[
"classic",
"freedom",
@@ -88,10 +104,10 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_logo: str = Field(default=None)
lnbits_ad_space_title: str = Field(default="Supported by")
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
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_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
@@ -111,8 +127,8 @@ class OpsSettings(LNbitsSettings):
class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: List[str] = Field(default=[])
lnbits_blocked_ips: List[str] = Field(default=[])
lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[])
lnbits_notifications: bool = Field(default=False)
lnbits_killswitch: bool = Field(default=False)
lnbits_killswitch_interval: int = Field(default=60)
@@ -141,7 +157,7 @@ class FakeWalletFundingSource(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_admin_key: Optional[str] = Field(default=None)
lnbits_invoice_key: Optional[str] = Field(default=None)
@@ -153,6 +169,7 @@ class ClicheFundingSource(LNbitsSettings):
class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: Optional[str] = Field(default=None)
corelightning_pay_command: str = Field(default="pay")
clightning_rpc: Optional[str] = Field(default=None)
@@ -172,6 +189,7 @@ class LndRestFundingSource(LNbitsSettings):
lnd_rest_cert: Optional[str] = Field(default=None)
lnd_rest_macaroon: Optional[str] = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True)
lnd_cert: Optional[str] = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None)
@@ -196,6 +214,16 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
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):
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
alby_access_token: Optional[str] = Field(default=None)
@@ -235,6 +263,8 @@ class FundingSourcesSettings(
LndGrpcFundingSource,
LnPayFundingSource,
AlbyFundingSource,
ZBDFundingSource,
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
LnTipsFundingSource,
@@ -268,7 +298,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: List[str] = Field(
auth_allowed_methods: list[str] = Field(
default=[
AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value,
@@ -342,6 +372,7 @@ class UpdateSettings(EditableSettings):
class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000)
@@ -357,8 +388,8 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time())
lnbits_extensions_deactivate_all: bool = Field(default=False)
cleanup_wallets_days: int = Field(default=90)
funding_source_max_retries: int = Field(default=4)
@property
def has_default_extension_path(self) -> bool:
@@ -377,20 +408,22 @@ class PersistenceSettings(LNbitsSettings):
class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: List[str] = Field(
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"VoidWallet",
"FakeWallet",
"CoreLightningWallet",
"CoreLightningRestWallet",
"LndRestWallet",
"EclairWallet",
"LndWallet",
"LnTipsWallet",
"LNPayWallet",
"AlbyWallet",
"FakeWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
"LNbitsWallet",
"LndRestWallet",
"LNPayWallet",
"LnTipsWallet",
"LndWallet",
"OpenNodeWallet",
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
]
)
@@ -403,6 +436,12 @@ class TransientSettings(InstalledExtensionsSettings):
# - are cleared on server restart
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
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
@@ -432,7 +471,7 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> "Settings":
def from_row(cls, row: Row) -> Settings:
data = dict(row)
return cls(**data)
@@ -457,7 +496,7 @@ class SuperSettings(EditableSettings):
class AdminSettings(EditableSettings):
is_super_user: bool
lnbits_allowed_funding_sources: Optional[List[str]]
lnbits_allowed_funding_sources: Optional[list[str]]
def set_cli_settings(**kwargs):
@@ -486,7 +525,7 @@ def send_admin_user_to_saas():
except Exception as e:
logger.error(
"error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {str(e)}"
f" {settings.lnbits_saas_callback}. Error: {e!s}"
)
@@ -512,10 +551,10 @@ if not settings.lnbits_admin_ui:
logger.debug(f"{key}: {value}")
def get_wallet_class():
def get_funding_source():
"""
Backwards compatibility
"""
from lnbits.wallets import get_wallet_class
from lnbits.wallets import get_funding_source
return get_wallet_class()
return get_funding_source()
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -550,3 +550,7 @@ video {
padding: 0.2rem;
border-radius: 0.2rem;
}
.whitespace-pre-line {
white-space: pre-line;
}
+22 -3
View File
@@ -36,7 +36,7 @@ window.localisation.br = {
name_your_wallet: 'Nomeie sua carteira %{name}',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay 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.',
export_to_phone: 'Exportar para o telefone com código QR',
export_to_phone_desc:
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
@@ -61,9 +61,10 @@ window.localisation.br = {
service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro',
payment_reactions: 'Reações de Pagamento',
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
api_docs: 'Documentação da API',
api_keys_api_docs: 'Chaves de API e documentação da API',
api_keys_api_docs: 'URL do Node, chaves da API e documentação da API',
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta',
@@ -98,6 +99,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.',
i_understand: 'Eu entendo',
copy_wallet_url: 'Copiar URL da carteira',
disclaimer_dialog_title: 'Importante!',
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.',
no_transactions: 'Ainda não foram feitas transações',
@@ -190,6 +192,12 @@ window.localisation.br = {
allow_access_hint: 'Permitir acesso por IP (substituirá os IPs bloqueados)',
enter_ip: 'Digite o IP e pressione enter',
rate_limiter: 'Limitador de Taxa',
wallet_limiter: 'Limitador de Carteira',
wallet_limit_max_withdraw_per_day:
'Retirada máxima diária da carteira em sats (0 para desativar)',
wallet_max_ballance: 'Saldo máximo da carteira em sats (0 para desativar)',
wallet_limit_secs_between_trans:
'Minutos e segundos entre transações por carteira (0 para desativar)',
number_of_requests: 'Número de solicitações',
time_unit: 'Unidade de tempo',
minute: 'minuto',
@@ -208,6 +216,7 @@ window.localisation.br = {
account_settings: 'Configurações da Conta',
signin_with_google: 'Entrar com o Google',
signin_with_github: 'Entrar com GitHub',
signin_with_keycloak: 'Entrar com Keycloak',
username_or_email: 'Nome de usuário ou E-mail',
password: 'Senha',
password_config: 'Configuração de Senha',
@@ -230,5 +239,15 @@ window.localisation.br = {
auth_provider: 'Provedor de Autenticação',
my_account: 'Minha Conta',
back: 'Voltar',
logout: 'Sair'
logout: 'Sair',
look_and_feel: 'Aparência',
language: 'Idioma',
color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
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'
}
+22 -4
View File
@@ -35,7 +35,7 @@ window.localisation.cn = {
name_your_wallet: '给你的 %{name}钱包起个名字',
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description:
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
export_to_phone: '通过二维码导出到手机',
export_to_phone_desc:
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
@@ -57,9 +57,10 @@ window.localisation.cn = {
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats',
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
toggle_darkmode: '切换暗黑模式',
payment_reactions: '支付反应',
view_swagger_docs: '查看 LNbits Swagger API 文档',
api_docs: 'API文档',
api_keys_api_docs: 'API密钥和API文档',
api_keys_api_docs: '节点URL、API密钥和API文档',
lnbits_version: 'LNbits版本',
runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户',
@@ -94,6 +95,7 @@ window.localisation.cn = {
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
i_understand: '我明白',
copy_wallet_url: '复制钱包URL',
disclaimer_dialog_title: '重要!',
disclaimer_dialog:
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
no_transactions: '尚未进行任何交易',
@@ -179,6 +181,11 @@ window.localisation.cn = {
allow_access_hint: '允许通过IP访问(将覆盖被屏蔽的IP)',
enter_ip: '输入IP地址并按回车键',
rate_limiter: '速率限制器',
wallet_limiter: '钱包限制器',
wallet_limit_max_withdraw_per_day:
'每日钱包最大提现额度(单位:sats)(设为0则禁用)',
wallet_max_ballance: '钱包最大余额(以sats计)(设为0则禁用)',
wallet_limit_secs_between_trans: '每个钱包交易间最少秒数(设为0则禁用)',
number_of_requests: '请求次数',
time_unit: '时间单位',
minute: '分钟',
@@ -196,7 +203,8 @@ window.localisation.cn = {
create_account: '创建账户',
account_settings: '账户设置',
signin_with_google: '使用谷歌账号登录',
signin_with_github: '使用 GitHub 登录',
signin_with_github: '使用GitHub登录',
signin_with_keycloak: '使用Keycloak登录',
username_or_email: '用户名或电子邮箱',
password: '密码',
password_config: '密码配置',
@@ -219,5 +227,15 @@ window.localisation.cn = {
auth_provider: '认证提供者',
my_account: '我的账户',
back: '返回',
logout: '注销'
logout: '注销',
look_and_feel: '外观和感觉',
language: '语言',
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: '创建新支付'
}
+22 -3
View File
@@ -35,7 +35,7 @@ window.localisation.cs = {
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, LNPay 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í.',
export_to_phone: 'Exportovat do telefonu pomocí QR kódu',
export_to_phone_desc:
'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.',
@@ -61,9 +61,10 @@ window.localisation.cs = {
service_fee_tooltip:
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
toggle_darkmode: 'Přepnout tmavý režim',
payment_reactions: 'Reakce na platby',
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
api_docs: 'API dokumentace',
api_keys_api_docs: 'API klíče a API dokumentace',
api_keys_api_docs: 'Adresa uzlu, API klíče a API dokumentace',
lnbits_version: 'Verze LNbits',
runs_on: 'Běží na',
credit_hint: 'Stiskněte Enter pro připsání na účet',
@@ -98,6 +99,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.',
i_understand: 'Rozumím',
copy_wallet_url: 'Kopírovat URL peněženky',
disclaimer_dialog_title: 'Důležité!',
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.',
no_transactions: 'Zatím žádné transakce',
@@ -187,6 +189,12 @@ window.localisation.cs = {
allow_access_hint: 'Povolit přístup podle IP (přepíše blokované IP)',
enter_ip: 'Zadejte IP a stiskněte enter',
rate_limiter: 'Omezovač počtu požadavků',
wallet_limiter: 'Omezení peněženky',
wallet_limit_max_withdraw_per_day:
'Maximální denní limit pro výběr z peněženky v sats (0 pro deaktivaci)',
wallet_max_ballance: 'Maximální zůstatek v peněžence v sats (0 pro zakázání)',
wallet_limit_secs_between_trans:
'Minimální počet sekund mezi transakcemi na peněženku (0 pro vypnutí)',
number_of_requests: 'Počet požadavků',
time_unit: 'Časová jednotka',
minute: 'minuta',
@@ -205,6 +213,7 @@ window.localisation.cs = {
account_settings: 'Nastavení účtu',
signin_with_google: 'Přihlásit se přes Google',
signin_with_github: 'Přihlásit se přes GitHub',
signin_with_keycloak: 'Přihlásit se přes Keycloak',
username_or_email: 'Uživatelské jméno nebo Email',
password: 'Heslo',
password_config: 'Konfigurace hesla',
@@ -227,5 +236,15 @@ window.localisation.cs = {
auth_provider: 'Poskytovatel ověření',
my_account: 'Můj účet',
back: 'Zpět',
logout: 'Odhlásit se'
logout: 'Odhlásit se',
look_and_feel: 'Vzhled a chování',
language: 'Jazyk',
color_scheme: 'Barevné schéma',
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
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'
}
+24 -3
View File
@@ -37,7 +37,7 @@ window.localisation.de = {
paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description:
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
export_to_phone: 'Auf dem Telefon öffnen',
export_to_phone_desc:
'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.',
@@ -63,9 +63,10 @@ window.localisation.de = {
service_fee_tooltip:
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
toggle_darkmode: 'Auf Dark Mode umschalten',
payment_reactions: 'Zahlungsreaktionen',
view_swagger_docs: 'LNbits Swagger API-Dokumentation',
api_docs: 'API-Dokumentation',
api_keys_api_docs: 'API-Schlüssel und API-Dokumentation',
api_keys_api_docs: 'Knoten-URL, API-Schlüssel und API-Dokumentation',
lnbits_version: 'LNbits-Version',
runs_on: 'Läuft auf',
credit_hint: 'Klicke Enter, um das Konto zu belasten',
@@ -101,6 +102,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.',
i_understand: 'Ich verstehe',
copy_wallet_url: 'Wallet-URL kopieren',
disclaimer_dialog_title: 'Wichtig!',
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.',
no_transactions: 'Keine Transaktionen',
@@ -192,6 +194,13 @@ window.localisation.de = {
allow_access_hint: 'Zugriff durch IP erlauben (überschreibt blockierte IPs)',
enter_ip: 'Geben Sie die IP ein und drücken Sie die Eingabetaste',
rate_limiter: 'Ratenbegrenzer',
wallet_limiter: 'Geldbeutel-Limiter',
wallet_limit_max_withdraw_per_day:
'Maximales tägliches Wallet-Auszahlungslimit in Sats (0 zum Deaktivieren)',
wallet_max_ballance:
'Maximales Guthaben der Wallet in Sats (0 zum Deaktivieren)',
wallet_limit_secs_between_trans:
'Mindestsekunden zwischen Transaktionen pro Wallet (0 zum Deaktivieren)',
number_of_requests: 'Anzahl der Anfragen',
time_unit: 'Zeiteinheit',
minute: 'Minute',
@@ -211,6 +220,7 @@ window.localisation.de = {
account_settings: 'Kontoeinstellungen',
signin_with_google: 'Mit Google anmelden',
signin_with_github: 'Anmelden mit GitHub',
signin_with_keycloak: 'Mit Keycloak anmelden',
username_or_email: 'Benutzername oder E-Mail',
password: 'Passwort',
password_config: 'Passwortkonfiguration',
@@ -233,5 +243,16 @@ window.localisation.de = {
auth_provider: 'Anbieter für Authentifizierung',
my_account: 'Mein Konto',
back: 'Zurück',
logout: 'Abmelden'
logout: 'Abmelden',
look_and_feel: 'Aussehen und Verhalten',
language: 'Sprache',
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'
}
+17 -4
View File
@@ -9,6 +9,8 @@ window.localisation.en = {
transactions: 'Transactions',
dashboard: 'Dashboard',
node: 'Node',
export_users: 'Export Users',
no_users: 'No users found',
total_capacity: 'Total Capacity',
avg_channel_size: 'Avg. Channel Size',
biggest_channel_size: 'Biggest Channel Size',
@@ -33,9 +35,11 @@ window.localisation.en = {
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
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 *',
lnbits_description:
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay 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.',
export_to_phone: 'Export to Phone with QR Code',
export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
@@ -62,7 +66,7 @@ window.localisation.en = {
payment_reactions: 'Payment Reactions',
view_swagger_docs: 'View LNbits Swagger API docs',
api_docs: 'API docs',
api_keys_api_docs: 'API keys and API docs',
api_keys_api_docs: 'Node URL, API keys and API docs',
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account',
@@ -97,8 +101,9 @@ window.localisation.en = {
'This is an LNURL-withdraw QR code for slurping everything from this wallet. Do not share with anyone. It is compatible with balanceCheck and balanceNotify so your wallet may keep pulling the funds continuously from here after the first withdraw.',
i_understand: 'I understand',
copy_wallet_url: 'Copy wallet URL',
disclaimer_dialog_title: 'Important!',
disclaimer_dialog:
'To ensure continuous access to your wallets, please remember to securely store your login credentials! Please visit the "My Account" page. This service is in BETA, and we hold no responsibility for people losing access to funds.',
'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.',
no_transactions: 'No transactions made yet',
manage: 'Manage',
extensions: 'Extensions',
@@ -235,5 +240,13 @@ window.localisation.en = {
logout: 'Logout',
look_and_feel: 'Look and Feel',
language: 'Language',
color_scheme: 'Color Scheme'
color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
show_qr: 'Show QR',
retry_install: 'Retry Install',
new_payment: 'Make New Payment',
hide_empty_wallets: 'Hide empty wallets'
}
+23 -3
View File
@@ -36,7 +36,7 @@ window.localisation.es = {
name_your_wallet: 'Nombre de su billetera %{name}',
paste_invoice_label: 'Pegue la factura aquí',
lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, LNPay 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.',
export_to_phone: 'Exportar a teléfono con código QR',
export_to_phone_desc:
'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.',
@@ -61,9 +61,10 @@ window.localisation.es = {
service_fee_tooltip:
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
toggle_darkmode: 'Cambiar modo oscuro',
payment_reactions: 'Reacciones de Pago',
view_swagger_docs: 'Ver documentación de API de LNbits Swagger',
api_docs: 'Documentación de API',
api_keys_api_docs: 'Claves de API y documentación de API',
api_keys_api_docs: 'URL del nodo, claves de API y documentación de API',
lnbits_version: 'Versión de LNbits',
runs_on: 'Corre en',
credit_hint: 'Presione Enter para cargar la cuenta',
@@ -98,6 +99,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.',
i_understand: 'Lo entiendo',
copy_wallet_url: 'Copiar URL de billetera',
disclaimer_dialog_title: '¡Importante!',
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.',
no_transactions: 'No hay transacciones todavía',
@@ -190,6 +192,13 @@ window.localisation.es = {
allow_access_hint: 'Permitir acceso por IP (anulará las IPs bloqueadas)',
enter_ip: 'Ingrese la IP y presione enter',
rate_limiter: 'Limitador de tasa',
wallet_limiter: 'Limitador de Cartera',
wallet_limit_max_withdraw_per_day:
'Límite diario de retiro de la cartera en sats (0 para deshabilitar)',
wallet_max_ballance:
'Saldo máximo de la billetera en sats (0 para desactivar)',
wallet_limit_secs_between_trans:
'Mín. segs entre transacciones por cartera (0 para desactivar)',
number_of_requests: 'Número de solicitudes',
time_unit: 'Unidad de tiempo',
minute: 'minuto',
@@ -209,6 +218,7 @@ window.localisation.es = {
account_settings: 'Configuración de la cuenta',
signin_with_google: 'Inicia sesión con Google',
signin_with_github: 'Inicia sesión con GitHub',
signin_with_keycloak: 'Iniciar sesión con Keycloak',
username_or_email: 'Nombre de usuario o correo electrónico',
password: 'Contraseña',
password_config: 'Configuración de Contraseña',
@@ -231,5 +241,15 @@ window.localisation.es = {
auth_provider: 'Proveedor de Autenticación',
my_account: 'Mi cuenta',
back: 'Atrás',
logout: 'Cerrar sesión'
logout: 'Cerrar sesión',
look_and_feel: 'Apariencia',
language: 'Idioma',
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'
}
+19 -4
View File
@@ -37,7 +37,7 @@ window.localisation.fi = {
paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description:
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, kuten LND, Core Lightning, OpenNode, Alby, LNPay ja jopa itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, ja jopa LNbits itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
export_to_phone: 'Käytä puhelimessa lukemalla QR-koodi',
export_to_phone_desc:
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voi lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
@@ -64,10 +64,10 @@ window.localisation.fi = {
service_fee_tooltip:
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä',
toggle_reactions: 'Käytä tapahtuma efektejä',
payment_reactions: 'Maksureaktiot',
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
api_docs: 'API-dokumentaatio',
api_keys_api_docs: 'API-avaimet ja -dokumentaatio',
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
lnbits_version: 'LNbits versio',
runs_on: 'Mukana menossa',
credit_hint: 'Hyväksy painamalla Enter',
@@ -102,6 +102,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.',
i_understand: 'Vakuutan ymmärtäväni',
copy_wallet_url: 'Kopioi lompakon URL',
disclaimer_dialog_title: 'Tärkeää!',
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.',
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
@@ -191,6 +192,12 @@ window.localisation.fi = {
allow_access_hint: 'Salli pääsy IP-osoitteen perusteella (ohittaa estot)',
enter_ip: 'Anna IP ja paina +',
rate_limiter: 'Toiston rajoitin',
wallet_limiter: 'Lompakon Rajoitin',
wallet_limit_max_withdraw_per_day:
'Maksimi päivittäinen lompakon nosto sateissa (0 poistaa käytöstä)',
wallet_max_ballance: 'Lompakon maksimisaldo satosheina (0 poistaa käytöstä)',
wallet_limit_secs_between_trans:
'Min sekuntia transaktioiden välillä lompakkoa kohden (0 poistaa käytöstä)',
number_of_requests: 'Pyyntöjen lukumäärä',
time_unit: 'aikayksikkö',
minute: 'minuutti',
@@ -209,6 +216,7 @@ window.localisation.fi = {
account_settings: 'Tilin asetukset',
signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
@@ -234,5 +242,12 @@ window.localisation.fi = {
logout: 'Poistu',
look_and_feel: 'Kieli ja värit',
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'
}
+24 -3
View File
@@ -39,7 +39,7 @@ window.localisation.fr = {
paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description:
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
export_to_phone: 'Exporter vers le téléphone avec un code QR',
export_to_phone_desc:
"Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.",
@@ -65,9 +65,10 @@ window.localisation.fr = {
service_fee_tooltip:
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
toggle_darkmode: 'Basculer le mode sombre',
payment_reactions: 'Réactions de paiement',
view_swagger_docs: "Voir les documentation de l'API Swagger de LNbits",
api_docs: "Documentation de l'API",
api_keys_api_docs: "Clés API et documentation de l'API",
api_keys_api_docs: 'URL du nœud, clés API et documentation API',
lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur',
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
@@ -102,6 +103,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.",
i_understand: "J'ai compris",
copy_wallet_url: "Copier l'URL du portefeuille",
disclaimer_dialog_title: 'Important !',
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.",
no_transactions: 'Aucune transaction effectuée pour le moment',
@@ -195,6 +197,13 @@ window.localisation.fr = {
"Autoriser l'accès par IP (cela passera outre les IP bloquées)",
enter_ip: "Entrez l'adresse IP et appuyez sur Entrée",
rate_limiter: 'Limiteur de débit',
wallet_limiter: 'Limiteur de portefeuille',
wallet_limit_max_withdraw_per_day:
'Retrait quotidien maximum du portefeuille en sats (0 pour désactiver)',
wallet_max_ballance:
'Solde maximum du portefeuille en sats (0 pour désactiver)',
wallet_limit_secs_between_trans:
'Minutes et secondes entre les transactions par portefeuille (0 pour désactiver)',
number_of_requests: 'Nombre de requêtes',
time_unit: 'Unité de temps',
minute: 'minute',
@@ -213,6 +222,7 @@ window.localisation.fr = {
account_settings: 'Paramètres du compte',
signin_with_google: 'Connectez-vous avec Google',
signin_with_github: 'Connectez-vous avec GitHub',
signin_with_keycloak: 'Connectez-vous avec Keycloak',
username_or_email: "Nom d'utilisateur ou e-mail",
password: 'Mot de passe',
password_config: 'Configuration du mot de passe',
@@ -235,5 +245,16 @@ window.localisation.fr = {
auth_provider: "Fournisseur d'authentification",
my_account: 'Mon compte',
back: 'Retour',
logout: 'Déconnexion'
logout: 'Déconnexion',
look_and_feel: 'Apparence',
language: 'Langue',
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'
}
+24 -3
View File
@@ -37,7 +37,7 @@ window.localisation.it = {
paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description:
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento Lightning Network e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
export_to_phone: 'Esportazione su telefono con codice QR',
export_to_phone_desc:
"Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.",
@@ -62,9 +62,10 @@ window.localisation.it = {
service_fee_tooltip:
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
toggle_darkmode: 'Attiva la modalità notturna',
payment_reactions: 'Reazioni al Pagamento',
view_swagger_docs: "Visualizza i documentazione dell'API Swagger di LNbits",
api_docs: "Documentazione dell'API",
api_keys_api_docs: "Chiavi API e documentazione dell'API",
api_keys_api_docs: 'URL del nodo, chiavi API e documentazione API',
lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su',
credit_hint: 'Premere Invio per accreditare i fondi',
@@ -99,6 +100,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',
i_understand: 'Ho capito',
copy_wallet_url: 'Copia URL portafoglio',
disclaimer_dialog_title: 'Importante!',
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",
no_transactions: 'Nessuna transazione effettuata',
@@ -191,6 +193,13 @@ window.localisation.it = {
"Consenti l'accesso per IP (sovrascriverà gli IP bloccati)",
enter_ip: "Inserisci l'IP e premi invio",
rate_limiter: 'Limitatore di frequenza',
wallet_limiter: 'Limitatore del Portafoglio',
wallet_limit_max_withdraw_per_day:
'Prelievo massimo giornaliero dal portafoglio in sats (0 per disabilitare)',
wallet_max_ballance:
'Saldo massimo del portafoglio in sats (0 per disabilitare)',
wallet_limit_secs_between_trans:
'Minuti e secondi tra transazioni per portafoglio (0 per disabilitare)',
number_of_requests: 'Numero di richieste',
time_unit: 'Unità di tempo',
minute: 'minuto',
@@ -210,6 +219,7 @@ window.localisation.it = {
account_settings: "Impostazioni dell'account",
signin_with_google: 'Accedi con Google',
signin_with_github: 'Accedi con GitHub',
signin_with_keycloak: 'Accedi con Keycloak',
username_or_email: 'Nome utente o Email',
password: 'Password',
password_config: 'Configurazione della password',
@@ -232,5 +242,16 @@ window.localisation.it = {
auth_provider: 'Provider di Autenticazione',
my_account: 'Il mio account',
back: 'Indietro',
logout: 'Esci'
logout: 'Esci',
look_and_feel: 'Aspetto e Comportamento',
language: 'Lingua',
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'
}
+22 -3
View File
@@ -35,7 +35,7 @@ window.localisation.jp = {
name_your_wallet: 'あなたのウォレットの名前 %{name}',
paste_invoice_label: '請求書を貼り付けてください',
lnbits_description:
'簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、Alby, LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
'簡単にインストールでき、軽量LNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
export_to_phone: '電話にエクスポート',
export_to_phone_desc:
'ウォレットを電話にエクスポートすると、ウォレットを削除する前にウォレットを復元できます。ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。',
@@ -59,9 +59,10 @@ window.localisation.jp = {
service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)',
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
toggle_darkmode: 'ダークモードを切り替える',
payment_reactions: '支払いの反応',
view_swagger_docs: 'Swaggerドキュメントを表示',
api_docs: 'APIドキュメント',
api_keys_api_docs: 'APIキーAPIドキュメント',
api_keys_api_docs: 'ノードURL、APIキーAPIドキュメント',
lnbits_version: 'LNbits バージョン',
runs_on: 'で実行',
credit_hint:
@@ -96,6 +97,7 @@ window.localisation.jp = {
drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します',
i_understand: '理解した',
copy_wallet_url: 'ウォレットURLをコピー',
disclaimer_dialog_title: '重要!',
disclaimer_dialog:
'ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。ウォレットを削除する前に、ウォレットをエクスポートしてください。',
no_transactions: 'トランザクションはありません',
@@ -188,6 +190,12 @@ window.localisation.jp = {
'IPによるアクセスを許可する(ブロックされたIPを上書きします)',
enter_ip: 'IPを入力してエンターキーを押してください',
rate_limiter: 'レートリミッター',
wallet_limiter: 'ウォレットリミッター',
wallet_limit_max_withdraw_per_day:
'1日あたりの最大ウォレット出金額をsatsで入力してください(0 で無効)。',
wallet_max_ballance: 'ウォレットの最大残高(sats)(0は無効)',
wallet_limit_secs_between_trans:
'トランザクション間の最小秒数(ウォレットごと)(0は無効)',
number_of_requests: 'リクエストの数',
time_unit: '時間単位',
minute: '分',
@@ -207,6 +215,7 @@ window.localisation.jp = {
account_settings: 'アカウント設定',
signin_with_google: 'Googleでサインイン',
signin_with_github: 'GitHubでサインイン',
signin_with_keycloak: 'Keycloakでサインイン',
username_or_email: 'ユーザー名またはメールアドレス',
password: 'パスワード',
password_config: 'パスワード設定',
@@ -229,5 +238,15 @@ window.localisation.jp = {
auth_provider: '認証プロバイダ',
my_account: 'マイアカウント',
back: '戻る',
logout: 'ログアウト'
logout: 'ログアウト',
look_and_feel: 'ルック・アンド・フィール',
language: '言語',
color_scheme: 'カラースキーム',
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。',
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。',
release_details_error: 'リリースの詳細を取得できません。',
pay_from_wallet: 'ウォレットから支払う',
show_qr: 'QRを表示',
retry_install: '再試行インストール',
new_payment: '新しい支払いを作成する'
}
+21 -3
View File
@@ -36,7 +36,7 @@ window.localisation.kr = {
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description:
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다. 현재 지원하는 예산 자원의 형태는 LND, Core Lightning, OpenNode, Alby, LNPay, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
export_to_phone_desc:
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
@@ -60,9 +60,10 @@ window.localisation.kr = {
service_fee_tooltip:
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환',
payment_reactions: '결제 반응',
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
api_docs: 'API 문서',
api_keys_api_docs: 'API 키와 API 문서',
api_keys_api_docs: '노드 URL, API 키와 API 문서',
lnbits_version: 'LNbits 버전',
runs_on: 'Runs on',
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
@@ -97,6 +98,7 @@ window.localisation.kr = {
'이는 선택된 지갑으로부터 모든 자금을 인출하는 LNURL-withdraw QR 코드입니다. 그 누구와도 공유하지 마세요. balanceCheck 및 balanceNotify 기능과 호환되며, 당신의 지갑은 첫 출금 이후로도 계속 자금을 끌어당기고 있을 수 있습니다.',
i_understand: '이해하였습니다',
copy_wallet_url: '지갑 URL 복사하기',
disclaimer_dialog_title: '중요!',
disclaimer_dialog:
'로그인 기능은 향후 업데이트를 통해 지원될 계획이지만, 현재로써는 이 페이지에 향후 다시 접속하기 위해 북마크 설정하는 것을 잊지 마세요! 이 서비스는 아직 BETA 과정에 있고, LNbits 개발자들은 자금 손실에 대해 전혀 책임을 지지 않습니다.',
no_transactions: '아직 아무런 거래도 이루어지지 않았습니다',
@@ -187,6 +189,11 @@ window.localisation.kr = {
allow_access_hint: 'IP 기준으로 접속 허용하기 (차단한 IP들을 무시합니다)',
enter_ip: 'IP 주소를 입력하고 Enter를 눌러주세요',
rate_limiter: '횟수로 제한하기',
wallet_limiter: '지갑 제한기',
wallet_limit_max_withdraw_per_day:
'일일 최대 지갑 출금액(sats) (0은 비활성화)',
wallet_max_ballance: '지갑 최대 잔액(sats) (0은 비활성화)',
wallet_limit_secs_between_trans: '지갑 당 거래 사이 최소 초 (0은 비활성화)',
number_of_requests: '요청 횟수',
time_unit: '시간 단위',
minute: '분',
@@ -205,6 +212,7 @@ window.localisation.kr = {
account_settings: '계정 설정',
signin_with_google: 'Google으로 로그인',
signin_with_github: 'GitHub으로 로그인',
signin_with_keycloak: 'Keycloak으로 로그인',
username_or_email: '사용자 이름 또는 이메일',
password: '비밀번호',
password_config: '비밀번호 설정',
@@ -227,5 +235,15 @@ window.localisation.kr = {
auth_provider: '인증 제공자',
my_account: '내 계정',
back: '뒤로',
logout: '로그아웃'
logout: '로그아웃',
look_and_feel: '외관과 느낌',
language: '언어',
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: '새로운 결제하기'
}

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