Compare commits

...
108 Commits
Author SHA1 Message Date
+3 bc81dc5a28 update main branch to 0.11.0 (#1994)
* [FIX] correct amount in fiat tracking (#1934)

* better debug log

* fix: convert msat to sat for fiat amount

* [CHORE] update to version 0.11.0 (#1933)

* [FIX] codeql for dev branch (#1935)

* [FEAT] create data folder if it doesnt exist (#1930)

* [FEAT] improve update_admin_settings (#1903)

* [FEAT] improve update_admin_settings

while working on the push notification pr i found it very hard just to update
2 settings inside the db, so i improved upon update_admin_settings.
now you just need to provide a dict with key/values you want to update inside db.

also debugging the endpoints for update_settings i found despite the type of `EditableSettings`
fastapi did in fact pass a dict.

* t

* use `EditableSettings` as param in update_settings

* fix settings model validation

we previously overrode the pydantic validation with our own method

* make `LnbitsSettings` a `BaseModel` and only add `BaseSettings` later

this allows us to instantiate `EditableSettings` without the environment values being loaded in

* add test

* forbid extra fields in update api

* fixup

* add test

* test datadir

* move UpdateSettings

* fix compat

* fixup webpush

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>

* [TEST] use clean db in postgres tests (#1928)

* [CORE] Document CoreLightningRestWallet (#1920)

* document CoreLightningRestWallet
* pushed to comment
* fixed base64 comment

---------

Co-authored-by: Ben <ben@arc.wales>

* [TEST] use test data as mockdata (#1929)

* add db group to cli

* delete mock_data.zip

* generate migration data through tests

* [BUG] self payments for the same wallet (#1914)

* [BUG] self payments for fakewallet

make it possible to pay to yourself on fakewallet

f

* bugfix selfpayments for fakewallet

* delete by wallet and fix previous_payment check (#1919)

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>

* [REFACTOR] core/__init__ to not have circular import issues (#1876)

* F541 fix

remove unused workflow and combine linters into one

add lnbits/static to ruff ignore
remote setupnode

ignore upgrades for mypy

ignore F401 for __init__ files

unused noqa

ignore upgrades for black

F821: undefine name

disabled and logged webhook_listener for opennode and lnpay because they are obvisouly not working

E402: module level import not at top of file

fixup

revert breaking changes wait for PR #1876

https://github.com/lnbits/lnbits/pull/1876

E721 fixes, only popped up for python3.9 not 3.10

[REFACTOR] core/__init__ to not have circular import issues

WIP

add db for backwards compat

fix pyright

make mypy happy again

pyright did not catch those, i think mypy got confused with relative imports. maybe we should use absolute ones everywhere

E402: module level import not at top of file

dont forget to add core_app

rebase on ruff pr

f

remo

format

* fix clnrest

* ignore E402 in conftest

* refactoring issues

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>

* make wallets name prettier! (#1918)

* make wallets name prettier!

* Update lnbits/core/templates/admin/index.html

Co-authored-by: dni  <office@dnilabs.com>

* Update lnbits/core/templates/admin/index.html

Co-authored-by: dni  <office@dnilabs.com>

---------

Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>

* Fix payments chart (#1851)

* feat: payment history endpoint

* test payment history

* use new endpoint in frontend

* refactor tests

* [CHORE] bugfix formatting prettier (#1936)

* do not show deleted wallets (#1942)

* do not show deleted wallets

* rename parameter for clarity

* extension loader: do not panic! (#1945)

* Revert "do not show deleted wallets (#1942)" (#1947)

This reverts commit 1ee2d53069.

* remove LNURL-p description_hash validation (#1951)

* [CHORE] update dependencies, unsafe pip packages, fastapi (#1609)

* d

* middleware needs to be initialised before startup

runs on python3.11

update secp256k1

update psycopg

* update fastapi to v0.103

* fix webpush

* bump dependencies

* ruff issue

* lock versions

* bump versions

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>

* [FEAT] Focus cursor to textarea (#1959)

* Focus cursor to textarea 

Fix need for additional click to textarea before pasting

* [FEAT] autofocus textarea on paste request

closes #1959

* format arba

* bundle

---------

Co-authored-by: dni  <office@dnilabs.com>

* add editorconfig config, fix issues (only whitespace changes) (#1958)

* adding bolt11 lib and removing bolt11.py from the codebase (#1817)

* add latest bolt11 lib

decode exception handling for create_payment
fix json response for decode
bugfix hexing description hash
improvement on bolt11 lib
update to bolt11 2.0.1
fix clnrest
* bolt 2.0.4
* refactor core/crud.py

* catch bolt11 erxception clnrest

* Updated guide for extension development for current extension management process (#1962)

* Startup optimization: nonblocking expiry check (#1943)

* nonblocking expiry check
* autoruff
* smaller interval

---------

Co-authored-by: dni  <office@dnilabs.com>

* Support custom path for installed extensions (#1940)

* feat: the more you fuck around the more you learn
* feat: backwards compatible static file loading
* refactor: update paths for extension files
* refactor: var renaming
* doc: update `LNBITS_EXTENSIONS_PATH` documentation
* fix: default folder install
* feat: install ext without external path
* doc: `PYTHONPATH` no longer required
* fix: add warnings
* fix: missing path
* refactor: re-order statements
* fix: hardcoded path separator

---------

Co-authored-by: dni  <office@dnilabs.com>

* [FEAT] Node Managment (#1895)

* [FEAT] Node Managment

feat: node dashboard channels and transactions
fix: update channel variables
better types
refactor ui
add onchain balances and backend_name
mock values for fake wallet
remove app tab
start implementing peers and channel management
peer and channel management
implement channel closing
add channel states, better errors
seperate payments and invoices on transactions tab
display total channel balance
feat: optional public page
feat: show node address
fix: port conversion
feat: details dialog on transactions
fix: peer info without alias
fix: rename channel balances
small improvements to channels tab
feat: pagination on transactions tab
test caching transactions
refactor: move WALLET into wallets module
fix: backwards compatibility
refactor: move get_node_class to nodes modules
post merge bundle fundle
feat: disconnect peer
feat: initial lnd support
only use filtered channels for total balance
adjust closing logic
add basic node tests
add setting for disabling transactions tab
revert unnecessary changes
add tests for invoices and payments
improve payment and invoice implementations
the previously used invoice fixture has a session scope, but a new invoice is required
tests and bug fixes for channels api
use query instead of body in channel delete
delete requests should generally not use a body
take node id through path instead of body for delete endpoint
add peer management tests
more tests for errors
improve error handling
rename id and pubkey to peer_id for consistency
remove dead code
fix http status codes
make cache keys safer
cache node public info
comments for node settings
rename node prop in frontend
adjust tests to new status codes
cln: use amount_msat instead of value for onchain balance
turn transactions tab off by default
enable transactions in tests
only allow super user to create or delete
fix prop name in admin navbar

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>

* [FEAT] cleanup GET /wallet endpoint, add wallet api routes (#1932)

* [FEAT] cleanup GET /wallet endpoint, add wallet api route
this removes the functionalitiy to create accounts and wallets via
the GET /wallet endpoint in generic.py

it add endpoints inside the api.py for it and the frontend is modified to use the api endpoints

this also simplifies for the `feat/login` for the route.

* remove stale generic tests and add api tests

* bug wrong endpoint create account

* vlad nitpick

* added checkif deleted is 404

* reload after renaming wallet

* another iteration with vlad

* create new wallet if it none exist

* fix delete refresh

* formatting

* Don't create user if allowed users is set (#1822)

* dont allow creating of users when allowed users are set
* add info to .env.example

---------

Co-authored-by: dni  <office@dnilabs.com>

* fix typos (#1969)

* fix: node ranks (#1968)

* [BUG] bundle is broken (#1972)

* [BUG] Fix bad transation call (#1973)

* fix: `:label="$('xxx')` to :label="$t('xxx')

* chore: commit bundles

* chore: remove extra file

* fix: catch sse exceptions (#1971)

* Add Markdown capability globally (#1965)

* add markdown capability globally

* add markdown to site description

* add showdown to package.json

remove it from vendor.json, its bundled

* formatting

* io

* Update lnbits/core/templates/core/index.html

---------

Co-authored-by: dni  <office@dnilabs.com>

* feat: remove commit version (#1980)

* nix: fix nixosModule and build (#1979)

* lnbits/settings: get LNBITS_COMMIT from envvar if available

There is no other way of setting the git commit at runtime, for build systems like Nix that allow you to hermetically define envvars derived from the source that will be available reproducibly at runtime

* nix: update potree2nix and nixosModule

This is a general refactor that gets everything building and passing the vmTest again

* Update nix/modules/lnbits-service.nix

---------

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

* catch exceptions in migrations for extensions (#1987)

* fix: admin_ui js error (#1982)

* fix: admin_ui js error

undefined formData.lnbits_allowed_currencies JS error in console

* vlad suggestion

* update: to 0.11.1

* revert: update

---------

Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: jacksn <jkranawetter05@gmail.com>
Co-authored-by: Ben <ben@arc.wales>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: arbadacarba <63317640+arbadacarbaYK@users.noreply.github.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Matthew Croughan <matt@croughan.sh>
2023-10-05 10:52:57 +02:00
fb98576431 [FEAT] Push notification integration into core (#1393)
* push notification integration into core

added missing component

fixed bell working on all pages
- made pubkey global template env var
- had to move `get_push_notification_pubkey` to `helpers.py` because of circular reference with `tasks.py`

formay

trying to fix mypy

added py-vapid to requirements

Trying to fix stub mypy issue

* removed key files

* webpush key pair is saved in db `webpush_settings`

* removed lnaddress extension changes

* support for multi user account subscriptions, subscriptions are stored user based

fixed syntax error

fixed syntax error

removed unused line

* fixed subscribed user storage with local storage, no get request required

* method is singular now

* cleanup unsubscribed or expired push subscriptions

fixed flake8 errors

fixed poetry errors

* updating to latest lnbits

formatting, rebase error

fix

* remove unused?

* revert

* relock

* remove

* do not create settings table use adminsettings

mypy

fix

* cleanup old code

* catch case when client tries to recreate existing webpush subscription e.g. on cleared local storage

* show notification bell on user related pages only

* use local storage with one key like array, some refactoring

* fixed crud import

* fixed too long line

* removed unused imports

* ruff

* make webpush editable

* fixed privkey encoding

* fix ruff

* fix migration

---------

Co-authored-by: schneimi <admin@schneimi.de>
Co-authored-by: schneimi <dev@schneimi.de>
Co-authored-by: dni  <office@dnilabs.com>
2023-09-11 14:48:49 +01:00
dni ⚡andGitHub 8f0c1f6a80 [REFACTOR] grpcio and protofbuf for lndgrpc is not optional (#1886)
also removed dead code def get_ssl_context

black
2023-09-11 14:24:37 +01:00
576e20d0cd Add deleted flag wallet (#1826)
* add deleted flag on wallets

set deleted on delete wallet

2 twelves

format

fail on create invoice

make deleted check on SQL query

nazi flake8

add_test

boom... it works and passes!!

* add app fixture

vlad's recommendations

add deleted

* Add deleted flag to Wallet

* restore crud

* do not check for wallet in services.py

* add deleted flag on wallets

set deleted on delete wallet

2 twelves

format

fail on create invoice

make deleted check on SQL query

nazi flake8

add_test

boom... it works and passes!!

* add app fixture

vlad's recommendations

* add deleted

* error checks

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2023-09-11 14:06:31 +01:00
dni ⚡andGitHub 6773a0f533 [REFACTOR] .super_user move it into data dir (#1900)
related to https://github.com/lnbits/lnbits/pull/1855
put the `.super_user` into our data_dir to not pollute our root dir
removed the weird mention to the url in the logs and mention
the `lnbits-cli superuser` instead

this is a breaking change for the bundlers, `cat .super_user` is not good anymore.
fix would be `cat data/.super_user` but for future compatibility i recommend
using `poetry run lnbits-cli superuser`
2023-09-11 12:19:19 +01:00
Djuri BaarsandGitHub 2c53445b5e Remove LNTXbot files (#1921) 2023-09-11 12:35:56 +02:00
dni ⚡andGitHub 62435b3723 [TEST] workflows, added 10s timeout to lncli subprocess and logging (#1910)
* [FIX] workflows

testing why workflwos unstable instable

* debug run_command

* add timeout to process run

* log lncli output

* try catch json exception in clnrest

* settle does not return a json

* add 10 minutes timeout to regtests if they ever get stuck

* better subprocess handling

* fix race condition

* test 3

* remove

* run nr 4

* increase waits for race conditions

* revert clnrest

* better fiat amount test
2023-08-30 11:01:32 +01:00
dni ⚡andGitHub 6efe1a156b [FEAT] add codecov token (#1908)
for better coverage reports
2023-08-28 13:49:33 +02:00
dni ⚡andGitHub 85b7591f6e update to version 0.10.10 (#1773) 2023-08-28 12:40:02 +01:00
callebtcandGitHub 172c277c2d fakewallet: set tag of description_hash invoice correctly (#1901) 2023-08-28 11:02:58 +01:00
jackstar12andGitHub 2623e9247a track fiat value of payments (#1789)
* save fiat_amounts on payment creation

* show fiat amount in frontend

* add lnbits_default_accounting_currency

* extract fiat calculation logic into service

* move all currency conversions to calc_fiat_amounts

move all conversion logic into create_invoice so extensions can benefit aswell

* show user-defined and wallet-defined currency in frontend

* remove sat from fiat units

* make bundle

* improve tests

* debug log
2023-08-28 11:00:59 +01:00
7a37e72915 [REFACTOR] replace async_wrap with run_sync (#1858)
* replace async_wrap with run_sync

formatting

remove unused fn

properly raise exception, not for timeout though

* [TEST] proper startup and shutdown (#1860)

* add proper startup / shutdown in tests

* fix event loop issues

because uvloop was installed in server.py which is not the main entry point when tests are ran.
this caused the loops referenced by the locks and queues to be a different one than the one used to run the tests
(only an issue in python 3.9)

* give openapi more time, does not matter anyway, regtest takes way longer

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>

remove uvloop

* fix test

* dont touch pyproject

* fix: install uvloop in conftest

not using uvloop at all causes tests to take way longer

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>
2023-08-28 10:59:56 +01:00
48f25488df [CHORE] cleanup cache and exception on make test (#1899)
* [CHORE] cleanup cache and exception on `make test`

moved the invalidate_forever task to proper location and use `create_permanent_task`.
and there was a Task never awaited Exception when running tests

* imrpove expiration test

* move cache into utils

* fix issue with pytest_asyncio

s

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>
2023-08-28 10:56:59 +01:00
dni ⚡andGitHub 6077edad8f [CHORE] remove redundant mocks (#1902)
* [CHORE] rmeove redundant mocks

* remove mock dependency
2023-08-28 10:56:20 +01:00
jackstar12andGitHub e50a7fb2d1 refactor exchange rates (#1847)
* simplify and cache exchange rate

note that exir was removed as a provider

* add binance as provider

* log exception

* add test

* add blockchain.com provider
2023-08-24 11:59:57 +01:00
7343d1e0a0 [REFACTOR] proper create_invoice responses for descriptions (#1863)
* [REFACTOR] proper create_invoice responses for descriptions

return a exception per error it is description hash or unhashed_description
keep types

* Update lnbits/core/views/api.py

Co-authored-by: michael1011 <me@michael1011.at>

* Update lnbits/core/views/api.py

Co-authored-by: michael1011 <me@michael1011.at>

---------

Co-authored-by: michael1011 <me@michael1011.at>
2023-08-24 12:58:10 +02:00
dni ⚡andGitHub 1efec9bb80 [REFACTOR] do not throw an exception in list_parse_fallback (#1891) 2023-08-24 12:50:38 +02:00
dni ⚡andGitHub c54f48ee73 [CI] improve on github workflows (#1889)
* [CI] making a proper github CI workflow
2023-08-24 12:36:37 +02:00
jackstar12andGitHub ac0ef07a77 optimize balances view (#1779)
* optimize balances view

flake8

flake8

* fix line length
2023-08-24 11:20:15 +01:00
dni ⚡andGitHub 39d717e34c improve on openapi metadata (#1795) 2023-08-24 10:52:12 +01:00
dni ⚡andGitHub 4e6f229db2 [CHORE] string formatting default length 88 (#1887)
* [CHORE] string formatting default length 88

uses blacks default off 88 and enabled autostringformatting

* formatting

* nitpicks jackstar

fix
2023-08-24 10:26:09 +01:00
dni ⚡andGitHub 2ab18544c3 [BUG] use WalletType for get_wallet_for_key fn (#1896) 2023-08-24 08:26:34 +02:00
dni ⚡andGitHub fe88320f08 [CHORE] update mypy and new issues (#1892)
* [CHORE] update mypy and new issues

lnbits/cache.py:21: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/extension_manager.py:210: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/db.py:110: error: Only instance methods can be decorated with @property  [misc]
lnbits/tasks.py:152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/tasks.py:171: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/core/services.py:520: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/app.py:520: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/app.py:525: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
lnbits/app.py:532: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]

* fix db.py
* fix mypy notes, type were not needed
2023-08-23 21:24:54 +02:00
dni ⚡andGitHub b0ad47b69b [CHORE] update pyln-client (#1894)
lock versions

fixup
2023-08-23 12:51:40 +02:00
dni ⚡andGitHub 88c07acc2c [FEAT] save fiat amount in api_create_invoice (#1869)
save user input into extra when he chooses a currency for api_create invoice
2023-08-23 12:45:19 +02:00
c4da1dfdce [REFACTOR] WalletType into enum (#1888)
* [REFACTOR] WalletType into enum

- move wallettype models from decorators.py into models.py
- use enum instead of int
- use HTTPStatus for consistency

* Update lnbits/core/models.py

Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>

* use dataclass

---------

Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
2023-08-23 12:41:22 +02:00
3a653630f1 Wallets: add cln-rest (#1775)
* receive and pay works

* fix linter issues

* import Paymentstatus from core.models

* fix test real payment

* fix get_payment_status check in lnbits

* fix tests?

* simplify

* refactor AsyncClient

* inline import of get_wallet_class

fixes the previous cyclic import

* invoice stream working

* add notes as a reminder to get rid of labels when cln-rest supports payment_hash

* create Payment dummy classmethod

* remove unnecessary fields from dummy

* fixes tests?

* fix model

* fix cln bug (#1814)

* auth header

* rename cln to corelightning

* add clnrest to admin_ui

* add to clnrest allowed sources

* add allowed sources to .env.example

* allow macaroon files

* add corelightning rest to workflow

* proper env names

* cleanup routine

* log wallet connection errors and fix macaroon clnrest

* print error on connection fails

* clnrest: handle disconnects faster

* fix test use of get_payment_status

* make format

* clnrest: add unhashed_description

* add unhashed_description to test

* description_hash test

* unhashed_description not supported by clnrest

* fix checking_id return in api_payments_create_invoice

* refactor test to use client instead of api_payments

* formatting, some errorlogging

* fix test 1

* fix other tests, paid statuses was missing

* error handling

* revert unnecessary changes (#1854)

* apply review of motorina0

---------

Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2023-08-23 08:59:39 +02:00
dni ⚡andGitHub bb5033d225 [FEAT] add lnbits-cli for commands.py (#1890)
- add superuser command for print
- add delete-settings command
2023-08-21 16:29:13 +02:00
dni ⚡andGitHub eb0b06f98c [DEV] add ruff for linting and combine linters into one workflow (#1875)
* remove unused and update black + precommit
* makefile add ruff remove unused
* F541 fix
* complete ruff ignore
* remove unused workflow and combine linters into one
add lnbits/static to ruff ignore
save preview and linelength for later
define target version for black
* ignore upgrades for mypy
* remove flake8
* ignore F401 for __init__ files
* unused pylint comment
* unused noqa
* ignore upgrades for black
* run linting on py3.9 and py3.10
* dont assume python
2023-08-19 07:38:04 +02:00
jackstar12andGitHub 642f9a4c00 Update payments on invoice creation (#1782)
* update payments on invoice creation
* remove unnecessary payment polling
2023-08-18 12:10:52 +02:00
jackstar12andGitHub 8a6e411a0d [REFACTOR] payments sse endpoint (#1781)
* exclude sse from gzip

* refactor sse endpoint

* cleanup imports
2023-08-18 11:05:14 +01:00
stackregisterandGitHub d4de78f1e8 Add description to POST v1/payments endpoint. (#1848) 2023-08-18 10:30:45 +01:00
1fd4d9d514 cancel all long-running tasks (#1793)
* add centralized task management

in order to properly cleanup all long-running tasks we have to keep a list of them

* use new task management functions

* unify shutdown events

* vlads suggestions

rename variable for create_task
wrap cancel() with try/catch

fixup

* rename func to coro

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-08-18 10:25:33 +01:00
dni ⚡andGitHub 65db43ace4 feat: catch wallet init exception, string formatting (#1846)
this catches exception on set_wallet_class and switches to VoidWallet if it fails
2023-08-18 10:23:57 +01:00
dni ⚡andGitHub 59acd3a2ef [REFACTOR] cleanup views/api.py (#1865)
pull out models
line lengths
change models for tests
2023-08-18 10:22:22 +01:00
dni ⚡andGitHub 905afc1f5c FEAT: add installed extensions to log (#1857)
for immediate vision of installed extensions
2023-08-18 10:13:37 +01:00
dni ⚡andGitHub c7cf743852 [TEST] run tests.yml on py39 and py310 (#1882)
i think it is a good idea to test both at least for fakewallet tests.
2023-08-18 09:50:56 +01:00
b43e7efa0b [FEATURE] Adding coverage for 'lntbs' signet prefix (#1881)
* Update bolt11-decoder.js

Adding coverage for 'lntbs' signet prefix

* adding 'make bundle' modified files

---------

Co-authored-by: enaples <enaples@users.noreply.github.com>
2023-08-18 09:48:45 +01:00
dni ⚡andGitHub c8414829d7 [TEST] dont install default extensions for tests (#1861) 2023-08-16 19:19:21 +02:00
dni ⚡andGitHub 5e03f8eed8 [TEST] update pytest-cov to remove warning (#1867)
cleanup this warning when running tests
2023-08-16 16:26:48 +02:00
dni ⚡andGitHub 511a0fd895 [CHORE] cln.py pyln-client is not optional (#1859)
* [refactor] pyln-client is not optional

some time ago we added pyln do main dependencies, its need for think like, saas or docker.
so this import was dead code

* stubs for pyln.client
2023-08-16 15:54:06 +02:00
dni ⚡andGitHub 2434518d21 [TEST] better logs (#1866)
now you can see which lnbits logs belongs to which tests! :)
2023-08-16 12:31:56 +01:00
dni ⚡andGitHub 26a0633d49 [CHORE] fix pyright warning in db.py (#1868)
```warning: Class methods should take a "cls" parameter (reportSelfClsParameterName)```
2023-08-16 12:41:49 +02:00
dni ⚡andGitHub f0a66e41fb [CHORE] flake8 issues E402, E721 and F821 (#1874)
* F821: undefine name
disabled and logged webhook_listener for opennode and lnpay because they are obviously not working
* E402: module level import not at top of file
* E721 fixes, only popped up for python3.9 not 3.10
2023-08-16 12:22:14 +02:00
dni ⚡andGitHub 355806608b [CHORE] E722 bare exception fix (#1871)
* [CHORE] E722 bare exception fix
remove all bare exceptions from codebase and change it in `.flake8`
2023-08-16 12:17:54 +02:00
jackstar12andGitHub 3aef1605be Fix csv export (#1852)
* fetch all payments for csv export

* include fiat amount and currency in export
2023-08-15 12:39:12 +01:00
dni ⚡andGitHub f096b51f70 [TEST] proper credit_wallet function from services (#1862) 2023-08-07 21:49:37 +02:00
michael1011andGitHub 03acf5e5ec UI: hide paste payment request in unsupported environments (#1853) 2023-08-03 20:38:05 +02:00
772ae2a1ab support uvicorn reload functionality (#1841)
* support uvicorn reload functionality
see:
https://github.com/encode/uvicorn/blob/8239373ec649b1a41179070b6ba2c8dc549df63e/uvicorn/main.py#L580-L582
* add docs for --reload

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-08-03 09:53:36 +02:00
jackstar12andGitHub 2577ce7f81 add cache utility (#1790)
* add simple caching utility

* test cache

* remove prefix, default on get

* check expiry in pop aswell

* remove unnecessary type

* improve invalidation task

increase default interval to 10 seconds - doesnt have to check that often.
instead of recreating the dict everytime mutate the existing one
2023-08-02 14:13:31 +02:00
callebtcandGitHub 9bc8a9db55 UI: paste payment request from clipboard (#1845)
* paste payment request from clipboard

* remove unelevated
2023-08-02 12:42:23 +01:00
callebtcandGitHub 9f40b3cdbd Tests: hold invoice tests pending during payment (#1839)
* tests: hold invoice tests pending during payment

* sigh...

* ok

* hash preimage

* preimage_hash == payment_hash

* should error

* check that payment is gone

* cancel task while in-flight

* lnbits endpoing return error

* return error

* add both options

* fix list

* respond to error

* return false if payment status errors

* outgoing invoice should be deleted after one status check

* test api_payments_create error
2023-07-31 20:25:22 +02:00
jackstar12andGitHub 1ab81f6d7e Bug: invalid type converter (#1842)
* temporary test case

* remove buggy converter
2023-07-31 10:21:45 +02:00
jackstar12andGitHub 67d3a4f359 remove nested filters (#1843)
* remove nested filters

* remove from openapi schema aswell
2023-07-31 10:21:30 +02:00
7f0c7138af TEST: add hold invoices to test helpers and a basic test (#1838)
* add hold invoices and basic test

* run request in parallel

* create_task

* dont cancel it

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2023-07-26 14:18:14 +02:00
jackstar12andGitHub dda6c1b3c1 websocket internal payment notifications (#1831)
* add send_payment_notification service
payment notifications are sent from multiple places with inconsistent and incomplete data
* adopt new send_payment_notification service
* add tests
2023-07-26 12:08:22 +02:00
Mariusz KogenandGitHub cf0a87582c Ads layout fix (#1835) 2023-07-26 10:38:45 +02:00
4b25714b31 Fix drawer (#1836)
* make sidebar above tabs on mobile
* Can we add padding instead
* fix: add padding for small screen
* make bundle... again

---------

Co-authored-by: Ben <ben@arc.wales>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2023-07-26 10:35:52 +02:00
Mariusz KogenandGitHub 0ff0abf442 Adding Start9 (#1834) 2023-07-25 15:38:54 +02:00
dni ⚡andGitHub 6ce0387e1c FEAT: proper failing invoice/payment responses for VoidWallet (#1805)
before that change anyone using the api just got a 500 error without any error message.
there was also a bug stacetrace in the logs each time.

flake8
2023-07-25 14:13:34 +01:00
dni ⚡andGitHub 427f155c76 follow redirect for lnurls (#1832)
closes #976
2023-07-25 11:06:37 +02:00
dni ⚡andGitHub a81a2b5a08 fix ugly printed error of uvicorn.error (#1833) 2023-07-24 13:25:47 +02:00
callebtcandGitHub 11fec7a889 [Wallets] CLN: fix pending state check (#1770)
* better checking

* flake8 fix

* make format

* invoices scope are function for tests

* invoice back to sessionbut keep real_invoice for now

* make format

* comment

* get payment by checking id and test
2023-07-24 12:00:41 +02:00
dni ⚡andGitHub f478c1848a update embit to 0.7.0 (#1777) 2023-07-21 10:42:35 +02:00
callebtcandGitHub 2d4c9f349b Wallets: Catch errors during cleanup (#1829)
* jetz aba

* catch RuntimeError
2023-07-21 09:50:50 +02:00
Tiago VasconcelosandGitHub befb288900 Fix admin notifications (#1803)
* fix save badge being on all the time

* make deep copy and add restart to notification popup
2023-07-21 09:50:18 +02:00
dni ⚡andGitHub 963fd5de50 add github workflow for checking openapi (#1551)
* add github workflow for checking openapi

* verbose errors

* actual fix? remove env_names from BaseSettings class?

* dont kill process in workflow

* poetry version 1.5.1 for openapiu
2023-07-19 12:28:27 +01:00
ArcandGitHub a3aafe4b6f Data returned from wallet websocket was not json (#1823)
* Fixed walet websocket json

* format
2023-07-19 12:14:03 +01:00
f384c5c366 Increase default invoice expiry to 1 hour (#1825)
* more reasonable default expiry

600 = 10 minutes which is a pretty short amount of time, for example when using lnbits fundingsource inside lnbits itself it would fail if you do a submarine swap or anything involved with hold invoices

i think 3600 = 1 hour should not make a difference in performance and is a way more reasonable expiry

* change default in .env.example

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2023-07-19 13:13:46 +02:00
dni ⚡andGitHub 153e22fb54 remove extensions dir completly, create it on startup (#1778)
* remove extensions dir compleltly, and create it on server.py start

* also ignore symlinks
2023-07-18 13:46:50 +02:00
dni ⚡andGitHub 213be79aaa quickfix for failing checks (#1819)
root package is needed for tests

root package is needed for mypy

removing testfile
2023-07-11 13:25:35 +02:00
dni ⚡andGitHub f7cbe74d77 Update poetry to 1.5.1 (#1804)
* update poetry to 1.5.1 inside workflows

* update lock file to poetry 1.5.1

* test run 1

* test run 2

* run 3

* try different sortorder

* cli should use --no-root

here  are the refereneces.
https://python-poetry.org/docs/basic-usage/#installing-dependencies-only
https://pip.pypa.io/en/stable/topics/local-project-installs/#regular-installs

* cache migrations aswell

* test flake

* optimize docker image for regtest building

* playng

* remove test run 1

* remove run 2

* remove run 3

* run 4

* run 6

* run 7

* run 8

* run 9

* run 10
2023-07-11 12:46:40 +02:00
ArcandGitHub d0d5c4d033 Update README.md (#1818) 2023-07-11 09:34:21 +01:00
ArcandGitHub 0adaf03de5 update favicon (#1811) 2023-07-07 10:21:24 +01:00
83a04d8f74 CI adjustment - Pinkify Legend (#1766)
Add files via upload
Update base.scss
* making the bundle for pinkify
fix flamingo

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-07-05 14:39:39 +02:00
dni ⚡andGitHub e13426a56b needs restart after saving killswitch (#1800)
fix prettier
2023-07-03 16:52:33 +02:00
6aaa46e5a0 Fix ip blocker 2nd try (#1796)
* fix ipblock v2

fix ipblock 2nd try

* remove sleep inside ip block

dont ignore other exceptions

typo

* remove asyncio

* Update lnbits/middleware.py

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2023-07-03 11:25:00 +02:00
callebtcandGitHub b9db3eafe0 remove sleep (#1797) 2023-07-03 11:12:09 +02:00
dni ⚡andGitHub ae9eb314cc fix bundle for 0.10.9 (#1792) 2023-07-03 10:29:03 +02:00
dni ⚡andGitHub fe393b83ab ip blocker only call next after validation (#1791)
* only call next after validation

* lets the blocked ips wait :)

* calles suggestion :)
2023-07-03 10:26:35 +02:00
jackstar12andGitHub bc55d52ea2 Refactor get walletclass (#1776)
* move `get_wallet_class` to wallets module

* adjust imports, fix type issues

flake8
2023-06-27 16:11:00 +02:00
dni ⚡andGitHub 9a0878de19 ignore upgrades dir in linters and formatters and precommit (#1783) 2023-06-26 10:38:40 +02:00
callebtcandGitHub be7c589722 add pre-commit-config (#1785) 2023-06-26 10:38:16 +02:00
dni ⚡andGitHub 28a55b2d41 precommit only check prettier if html/css/js/json (#1786)
add precommit checks only for types
2023-06-26 10:35:39 +02:00
callebtcandGitHub d66c83c91a no timeout for the sse connection (#1784) 2023-06-26 10:02:49 +02:00
7e1f43933d Adds security tools, such as a rate limiter, IP block/allow, server logs (#1606)
* added ratelimiter
* Adds server logs to admin ui
* Added IP allow/ban list
* fixed remove ips
* Split rate limit number and unit
* security tab and background tasks for killswitch
* fix test for auditor api

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-06-20 11:26:33 +02:00
Tiago VasconcelosandGitHub 758a4ecaf6 Fix user label across lnbits (#1771)
* check for super user on get_user
* remove unecessary assingments
2023-06-20 08:41:57 +02:00
callebtcandGitHub 339645a912 docs: add precommit hooks (#1772)
* docs: add precommit hooks
* adjust test
2023-06-20 08:41:23 +02:00
9014e73529 Create cn.js (#1768)
* Create cn.js
Add chinese translation
* added additional files for chinese language
* Update lnbits/templates/base.html

---------

Co-authored-by: hodlbaby <52352588+hodlbaby@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2023-06-20 08:41:04 +02:00
48fc73d9d4 Ability to limit available fiat currencies (#1748)
* feat: limit number of available fiat currencies
* feat: Add allowed currency selector to Admin UI
* motorinas suggestions

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-06-19 12:24:19 +02:00
OrenandGitHub 150a33009d s/gihub/github/g (#1767) 2023-06-19 12:22:53 +02:00
jackstar12andGitHub e6499104c0 Wallets refactor (#1729)
* feat: cleanup function for wallet

* update eclair implementation

* update lnd implementation

* update lnbits implementation

* update lnpay implementation

* update lnbits implementation

* update opennode implementation

* update spark implementation

* use base_url for clients

* fix lnpay

* fix opennode

* fix lntips

* test real invoice creation

* add small delay to test

* test paid invoice stream

* fix lnbits

* fix lndrest

* fix spark

fix spark

* check node balance in test

* increase balance check delay

* check balance in pay test aswell

* make sure get_payment_status is called

* fix lndrest

* revert unnecessary changes
2023-06-19 12:12:00 +02:00
Vlad StanandGitHub 49411e58cc Fix: extension links (#1764)
* fix: open extension link
* fix: do not show `Enable` for admin extensions to non-admin users
2023-06-15 19:06:21 +02:00
Vlad StanandGitHub 8c0e7725de Add option to drop extension db at un-install time or later (#1746)
* chore: remove un-used file
* feat: allow extension DB clean-up
* feat: i18n and bundle update
* chore: code format
* fix: button color
* chore: delete temp file
* chore: fix merge conflicts
* chore: add extra log
* chore: bump CACHE_VERSION to `37`
2023-06-15 16:22:18 +02:00
Vlad StanandGitHub 95281eba8c feat: show extension repo as tooltip for each repo (#1743) 2023-06-15 16:08:24 +02:00
ArcandGitHub cd17b59a3a Change asdict to dict (#1757) 2023-06-15 15:44:44 +02:00
dni ⚡andGitHub ec623f4861 remove settings logging (security) (#1763) 2023-06-15 15:44:08 +02:00
7c479a3ebf Hide the superuser ID URL from logs with comment where to find superuser ID (#1749)
* Hide the superuser ID URL from logs with comment where to find admin ID
* Update admin_ui.md

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-06-15 15:34:39 +02:00
dni ⚡andGitHub 9017cee491 quickfix spacing und mobile footer (#1740) 2023-06-15 15:32:10 +02:00
dni ⚡andGitHub 2940716807 bump to version 0.10.9 (#1742) 2023-06-15 15:30:39 +02:00
dni ⚡andGitHub c48e2329c5 TEST: fix tests for real wallets (#1761)
* fix tests for real wallets
* change realinvoice provider to lnd-1
* use lnd-3 for tests
* fix eclair tests
* use bullseye in dockerimage
2023-06-15 12:36:28 +02:00
dni ⚡andGitHub 8ed2422ade highlight active language #1730 (#1741) 2023-06-01 11:33:53 +01:00
Vlad StanandGitHub af660fec14 fix: ext short description for pre-installed extensions (#1744)
* fix: update `installed_extensions` entry after the ext has been unzipped

* fix: show installedRelease description as fallback
2023-05-26 10:48:40 +01:00
ArcandGitHub 6bbebe0cee Fixes and expands payments table csv (#1739) 2023-05-25 05:29:15 +01:00
ArcandGitHub f863a043cb Adds translations for send/receive (#1738)
* Send/receive translation

* bundle fundle
2023-05-24 20:59:34 +01:00
ArcandGitHub 2216649412 remove msat fee to sat (#1737)
* remove msat fee

* Fixed some formatting
2023-05-24 20:27:56 +01:00
ArcandGitHub 9d8c78c927 added msats (#1736) 2023-05-24 19:47:54 +01:00
ArcandGitHub 02bf2f9819 Better wallet format (#1735)
* Fixing big inout

* btter wallet format
2023-05-24 15:44:28 +01:00
155 changed files with 15222 additions and 3973 deletions
+14
View File
@@ -0,0 +1,14 @@
# Top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file, utf-8 charset
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[/lnbits/static/vendor/*]
end_of_line = unset
insert_final_newline = unset
trim_trailing_whitespace = unset
+25 -9
View File
@@ -8,7 +8,14 @@ PORT=5000
DEBUG=false
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
LNBITS_RATE_LIMIT_UNIT="minute"
LNBITS_ALLOWED_IPS=""
LNBITS_BLOCKED_IPS=""
# Allow users and admins by user IDs (comma separated list)
# if set new users will not be able to create accounts
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access
@@ -34,6 +41,11 @@ LNBITS_HIDE_API=false
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
@@ -52,6 +64,9 @@ LNBITS_RESERVE_FEE_MIN=2000
# value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0
# Limit fiat currencies allowed to see in UI
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
@@ -60,15 +75,15 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, 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.
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=600
LIGHTNING_INVOICE_EXPIRY=3600
# Set one of these blocks depending on the wallet kind you chose above:
@@ -82,6 +97,12 @@ SPARK_TOKEN=myaccesstoken
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# CoreLightningRestWallet
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
# Path or BASE64/HEX STRING
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon"
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
# LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
@@ -124,8 +145,3 @@ ECLAIR_PASS=eclairpw
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# Cashu Mint
# Use a long-enough random (!) private key.
# Once set, you cannot change this key as for now.
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
-16
View File
@@ -1,16 +0,0 @@
[flake8]
max-line-length = 150
exclude = lnbits/wallets/lnd_grpc_files/, lnbits/extensions/
ignore =
# E203 whitespace before ':' black does not like it
E203
# E402: module level import not at top of file
E402,
# W503: line break before binary operator
W503,
# F821: undefined name - should be addressed in future PR
F821,
# E722 do not use bare 'except' - should be addressed in future PR
E722,
# flake8-requirements import checks
I
+67
View File
@@ -0,0 +1,67 @@
name: prepare
inputs:
python-version:
description: "Python Version"
required: true
default: "3.9"
poetry-version:
description: "Poetry Version"
default: "1.5.1"
node-version:
description: "Node Version"
default: "18.x"
npm:
description: "use npm"
default: false
type: boolean
runs:
using: "composite"
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
cache: "pip"
- name: Set up Poetry ${{ inputs.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
shell: bash
run: poetry install
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.node-version }}
- uses: actions/cache@v3
if: ${{ (inputs.npm == 'true') }}
name: Define a cache for the npm based on the dependencies lock file
with:
path: ./node_modules
key: npm-${{ hashFiles('package-lock.json') }}
- name: Install npm packages
if: ${{ (inputs.npm == 'true') }}
shell: bash
run: npm install
+47
View File
@@ -0,0 +1,47 @@
name: LNbits CI
on: [push, pull_request]
jobs:
lint:
uses: ./.github/workflows/lint.yml
tests:
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:
python-version: ${{ matrix.python-version }}
migrations:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9"]
uses: ./.github/workflows/tests.yml
with:
make: test-migration
db-name: migration
openapi:
needs: [ lint ]
uses: ./.github/workflows/make.yml
with:
make: openapi
# docker:
# uses: ./.github/workflows/docker.yml
regtest:
needs: [ lint ]
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
python-version: ["3.9"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
+2 -2
View File
@@ -2,9 +2,9 @@ name: codeql
on:
push:
branches: [main, ]
branches: [main, dev]
pull_request:
branches: [main]
branches: [main, dev]
schedule:
- cron: '0 12 * * 5'
+20
View File
@@ -0,0 +1,20 @@
name: docker
on:
workflow_call:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: false
tags: lnbitsdocker/lnbits-legend:latest
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
cache-to: type=inline
-27
View File
@@ -1,27 +0,0 @@
name: flake8
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: make flake8
-33
View File
@@ -1,33 +0,0 @@
name: formatting
on: [push, pull_request]
jobs:
checks:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install packages
run: |
poetry install
npm install prettier
- name: Check black
run: make checkblack
- name: Check isort
run: make checkisort
- uses: actions/setup-node@v3
- name: Check prettier
run: make checkprettier
+27
View File
@@ -0,0 +1,27 @@
name: lint
on:
workflow_call:
jobs:
black:
uses: ./.github/workflows/make.yml
with:
make: checkblack
ruff:
uses: ./.github/workflows/make.yml
with:
make: checkruff
mypy:
uses: ./.github/workflows/make.yml
with:
make: mypy
pyright:
uses: ./.github/workflows/make.yml
with:
make: pyright
npm: true
prettier:
uses: ./.github/workflows/make.yml
with:
make: prettier
npm: true
+31
View File
@@ -0,0 +1,31 @@
name: make
on:
workflow_call:
inputs:
make:
description: "make command that is run"
required: true
type: string
npm:
description: "use npm install"
default: false
type: boolean
jobs:
make:
name: ${{ inputs.make }} (${{ matrix.python-version }})
strategy:
matrix:
os-version: ["ubuntu-latest"]
python-version: ["3.9", "3.10"]
node-version: ["18.x"]
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/prepare
with:
python-version: ${{ matrix.python-version }}
node-version: ${{ matrix.node-version }}
npm: ${{ inputs.npm }}
- run: make ${{ inputs.make }}
-43
View File
@@ -1,43 +0,0 @@
name: migrations
on: [pull_request]
jobs:
sqlite-to-postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: migration
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies
run: |
poetry install
sudo apt install unzip
- name: Run migrations
run: |
make test-migration
-27
View File
@@ -1,27 +0,0 @@
name: mypy
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: make mypy
-27
View File
@@ -1,27 +0,0 @@
name: pylint
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: make pylint
-28
View File
@@ -1,28 +0,0 @@
name: pyright
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
npm install
- name: Run tests
run: make pyright
+54 -196
View File
@@ -1,223 +1,81 @@
name: regtest
on: [push, pull_request]
on:
workflow_call:
inputs:
make:
default: test
type: string
python-version:
default: "3.9"
type: string
os-version:
default: "ubuntu-latest"
type: string
backend-wallet-class:
required: true
type: string
jobs:
LndRestWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
regtest:
runs-on: ${{ inputs.os-version }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
- name: Set up Docker Buildx
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:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
context: .
push: false
tags: lnbitsdocker/lnbits-legend:latest
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
cache-to: type=inline
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Create fake admin
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py
- name: Run Tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LndWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LndWallet
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
CoreLightningWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LNbitsWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
docker exec lnbits-legend-lnbits-1 /bin/bash -c "poetry run python tools/create_fake_admin.py"
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LNbitsWallet
CORELIGHTNING_REST_URL: https://localhost:3001
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
EclairWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: EclairWallet
ECLAIR_URL: http://127.0.0.1:8082
ECLAIR_PASS: lnbits
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
run: make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
+35 -47
View File
@@ -1,70 +1,58 @@
name: tests
on: [push, pull_request]
on:
workflow_call:
inputs:
make:
default: test
type: string
python-version:
default: "3.9"
type: string
os-version:
default: "ubuntu-latest"
type: string
db-url:
default: ""
type: string
db-name:
default: "lnbits"
type: string
jobs:
sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: make test
postgres:
runs-on: ubuntu-latest
tests:
runs-on: ${{ inputs.os-version }}
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: ${{ inputs.db-name }}
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
- uses: ./.github/actions/prepare
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- name: Run tests
python-version: ${{ inputs.python-version }}
- name: Run Tests
env:
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
run: make test
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: make ${{ inputs.make }}
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
+2 -2
View File
@@ -23,7 +23,7 @@ tests/data/*.sqlite3
*.pyc
*.env
.env
.super_user
.pre-commit-config.yaml
data
*.sqlite3
@@ -46,5 +46,5 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
extensions/
extensions
upgrades/
+7 -11
View File
@@ -1,4 +1,4 @@
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/upgrades/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -14,21 +14,17 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 22.6.0
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283
hooks:
- id: isort
args: ['--profile', 'black']
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
hooks:
- id: prettier
types_or: [css, javascript, html, json]
args: ['lnbits']
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
entry: poetry run flake8
+1
View File
@@ -6,6 +6,7 @@
*.yml
**/lnbits/extensions/*
**/lnbits/upgrades/*
**/lnbits/static/vendor
**/lnbits/static/bundle.*
+2 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim
FROM python:3.10-slim-bullseye
RUN apt-get clean
RUN apt-get update
@@ -8,7 +8,7 @@ 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 wget
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-get update
@@ -20,7 +20,6 @@ COPY . .
RUN mkdir data
RUN poetry config virtualenvs.create false
RUN poetry install --only main
ENV LNBITS_PORT="5000"
+29 -19
View File
@@ -2,9 +2,9 @@
all: format check
format: prettier isort black
format: prettier black ruff
check: mypy pyright pylint flake8 checkisort checkblack checkprettier
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write lnbits
@@ -12,20 +12,17 @@ prettier:
pyright:
poetry run ./node_modules/.bin/pyright
black:
poetry run black .
flake8:
poetry run flake8
mypy:
poetry run mypy
isort:
poetry run isort .
black:
poetry run black .
pylint:
poetry run pylint *.py lnbits/ tools/ tests/
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check lnbits
@@ -33,8 +30,11 @@ checkprettier:
checkblack:
poetry run black --check .
checkisort:
poetry run isort --check-only .
checkeditorconfig:
editorconfig-checker
dev:
poetry run lnbits --reload
test:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
@@ -51,24 +51,34 @@ test-real-wallet:
poetry run pytest
test-migration:
rm -rf ./migration-data
mkdir -p ./migration-data
unzip tests/data/mock_data.zip -d ./migration-data
LNBITS_ADMIN_UI=True \
make test
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./migration-data" \
LNBITS_DATA_FOLDER="./tests/data" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./migration-data" \
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
poetry run python tools/conv.py
migration:
poetry run python tools/conv.py
openapi:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
PORT=5003 \
poetry run lnbits &
sleep 15
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
# kill -9 %1
bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
#
+2 -2
View File
@@ -31,7 +31,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
## Running LNbits
See the [install guide](https://github.com/lnbits/lnbits/wiki/LNbits-Documentation#install-options) for details on installation and setup.
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
## LNbits as an account system
@@ -52,7 +52,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
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.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
+1 -1
View File
@@ -1 +1 @@
docs.lnbits.org
docs.lnbits.org
+24
View File
@@ -12,6 +12,30 @@ For developers
Thanks for contributing :)
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
=====
This ensures that all commits adhere to the formatting and linting rules.
```bash
make install-pre-commit-hook
```
Tests
=====
+16 -5
View File
@@ -6,13 +6,13 @@ nav_order: 2
---
Making extensions
Extension set up
=================
Start off by cloning the [example extension](https://github.com/lnbits/example) into your `lnbits/extensions` folder and renaming it to `mysuperplugin`:
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
```sh
cd lnbits/extensions
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
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.
cd mysuperplugin
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'.
@@ -20,7 +20,18 @@ 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.
Going over the example extension's structure:
1. Edit `manifest.json` and change the organisation name to your GitHub username.
1. Push your changes to GitHub.
1. In GitHub create a new release for your extension repo. Tag the release with `0.0.1`
1. Copy the URL of the extension's raw `manifest.json` URL `https://raw.githubusercontent.com/[my-user-name]/mysuperplugin/master/manifest.json`
1. If you are using the LMNbits Admin UI, go to the Admin UI > Server > Extension Sources, click "Add", paste the URL, then click "Save"
1. If you are configuring LNbits via environment variables, add the URL to the .env file's `LNBITS_EXTENSIONS_MANIFESTS` variable. Restart the LNbits python process
1. You will now see your extension in the LNbits > Extensions list. Click "Enable" to enable it.
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.
+1 -1
View File
@@ -9,7 +9,7 @@ nav_order: 2
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client.
`websockets` are a great way to add a two way instant data channel between server and client.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
+8 -7
View File
@@ -52,14 +52,15 @@ $ sudo nano .env
-> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
It will now show you the Super User Account:
`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
$ poetry run lnbits
```
You can now `cat` the Super User ID:
```
$ cat .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`
+5 -5
View File
@@ -15,7 +15,7 @@ An `Extension Manifest` is a link to a `JSON` file which contains information ab
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section.
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
> **Warning**
> Extensions can have bugs or malicious code, be careful what you install!!
@@ -40,9 +40,9 @@ Click the `Manage` button in order to install a particular release of the extens
Select the version to be installed (usually the last one) and click `Install`. One can also check the `Release Notes` first.
> **Note**:
>
>
> For Github repository: the order of the releases is the one in the GitHub releases page
>
>
> For Explicit Release: the order of the releases is the one in the "extensions" object
@@ -109,12 +109,12 @@ 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:
+27 -27
View File
@@ -19,24 +19,24 @@ allow-self-payment=1
```
</details>
<details><summary>Funding source only available via tor (e.g. Umbrel)</summary>
<details><summary>Funding source only available via tor (e.g. Start9 or Umbrel)</summary>
<p>If you want your setup to stay behind tor then only apps, pos and wallets that have tor activated can communicate with your wallets. Most likely you will have trouble when people try to redeem your voucher through onion or when importing your lnbits wallets into a wallet-app that doesnt support tor. If you plan to let LNbits wallets interact with plain internet shops and services you should consider <a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">setting up hybrid mode for your node</a>.</p>
</details>
<details><summary>Funding source is in a cloud</summary>
<p>This means that you might not have access to some files which would allow certain administrative functions. E.g. on <a href="https://voltage.cloud/">Voltage</a> lnd.conf can not be edited. Payments from your node to LNbits wallets can therefore not be configurated in this case atm so you will need to take an extra wallet to send from funding source->wallet x->LNbits wallet (only) for the initial funding of the wallet.</p>
</details>
<details><summary>LNbits via clearnet domain</summary>
<p><a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">Step by step guide how to convert your Tor only node</a> into a clearnet node to make apps like LNbits accessible via https.</p>
</details>
<details><summary>Which funding sources can I use for LNbits?</summary>
<p>There are several ways to run a LNbits instance funded from different sources. It is important to choose a source that has a good liquidity and good peers connected. If you use LNbits for public services your users´ payments can then flow happily in both directions. If you would like to fund your LNbits wallet via btc please see section Troubleshooting.</p>
<p>The <a href="http://docs.lnbits.org/guide/wallets.html">LNbits manual</a> shows you which sources can be used and how to configure each: CLN, LND, LNPay, Cliche, OpenNode as well as bots.</p>
</details>
<!--Later to be added
<details><summary>Advanced setup options</summary>
<p>more text coming soon...</p>
@@ -61,7 +61,7 @@ allow-self-payment=1
<details><summary>LNbits is running via Tor only, you can't open it on a public domain like lnbits.yourdomain.com</summary>
<ul>
<li>Given that you want your setup to stay like this open your LNbits wallet using the .onion URI and create it again. In this way the QR is generated to be accessible via this .onion URI so via tor only. Do not generate that QR from a .local URI, because it will not be reachable via internet - only from within your home-LAN.</li>
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
If the app doesn't offer tor, you can use Orbot (Android) instead. See section Installation->Clearnet for detailed instructions.</li>
</ul>
</details>
@@ -70,10 +70,10 @@ allow-self-payment=1
<details><summary>LNbits is running via Tor only, you want to offer public LN services via https</summary>
<ul>
<li>For this we need to partially open LNbits to a clearnet (domain/IP) through a https SSL certificate. Follow the instructions from <a href="https://docs.lnbits.org/guide/installation.html#reverse-proxy-with-automatic-https-using-caddy">this LNbits caddy installation instruction</a>.
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
You also need access to your internet router to open the https port (usually 443) and forward it your LNbits IP within your LAN (usually 80). The ports might depend on your node implementation if those ports do not work please ask for them in a help group of your node supplier.</li>
<li>You can also follow the Apache installation option, explained in the <a href="https://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https">LNbits installation manual</a>.</li>
<li>If you run LNbits from a bundle node (Umbrel, myNode, Embassy, Raspiblitz etc), you can follow <a href="https://github.com/TrezorHannes/vps-lnbits">this extensive guide</a> with many options to switch your Tor only LNbits into a clearnet LNbits. For Citadel there is a HTTPS Option in your manual to activate https for LNbits in the newest version.</li>
<li>If you run LNbits from a bundle node (myNode, Start9, Umbrel,Raspiblitz etc), you can follow <a href="https://github.com/TrezorHannes/vps-lnbits">this extensive guide</a> with many options to switch your Tor only LNbits into a clearnet LNbits. For Citadel there is a HTTPS Option in your manual to activate https for LNbits in the newest version.</li>
</ul>
</details>
</li>
@@ -89,9 +89,9 @@ allow-self-payment=1
</li>
<li>
<details><summary>Wallet on your own funding source/node</summary>
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
You can open and edit it with excel or with a dedicated SQL-Editor like <a href="https://sqlitebrowser.org/">SQLite browser</a>.</p>
</details>
</li>
@@ -100,7 +100,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 lenght 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)
@@ -113,16 +113,16 @@ allow-self-payment=1
<li>
<details><summary>A - Via an external swap service</summary>
<p>If the user do not have full access of your LNbits, is just an external user, can use swap services like <a href="https://boltz.exchange/">Boltz</a>, <a href="https://fixedfloat.com/">FixedFloat</a>, <a href="https://swap.diamondhands.technology/">DiamondHands</a> or <a href="https://zigzag.io/">ZigZag</a>.</p>
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
they will have to the swap those sats first on their side.</p>
<p>The procedure is simple: user sends onchain btc to the swap service and provides the LNURL / LN invoice from LNbits as destination of the swap.</p>
</details>
</li>
<li>
<details><summary>B - Using the Onchain LNbits extension</summary>
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
This leads to more space in your LN channels to be able to receive new fresh sats.</p>
<p>Procedure:</p>
<ul>
@@ -130,25 +130,25 @@ allow-self-payment=1
<li>Go to wallet information and copy the xpub</li>
<li>Go to LNbits - Onchain extension and create a new watch-only wallet with that xpub</li>
<li>Go to LNbits - Tipjar extension and create a new Tipjar. Select also the onchain option besides the LN wallet.</li>
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
You can choose between onchain and LN or both. It will then create an invoice that can be shared.</li>
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
the customer will have both options to pay on the same screen.</li>
</ul>
</details>
</li>
</ul>
</details>
<details><summary>Where can I see payment details?</summary>
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
![lnbits-tx-log.png](https://i.postimg.cc/gk2FMFG9/lnbits-tx-log.png)
<p>In your transaction overview you will find a little green arrow for received and a red arrow for sended funds.<p>
<p>If you click on those arrows, a details popup shows attached messages as well as the sender´s name if given.</p>
</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>
![lnbits-tx-details.png](https://i.postimg.cc/yYnvyK4w/lnbits-tx-details.png)
@@ -171,7 +171,7 @@ allow-self-payment=1
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
<p>This is a do-it-yourself project consisting of a mini-computer (Raspberry Pi Zero), a coin acceptor, a display, a 3D printed case, and a Bitcoin Lightning wallet as a funding source. It exchanges fiat coins for valuable Bitcoin Lightning ⚡ Satoshis. The user can pick up the Satoshis via QR code (LNURL-withdraw) on a mobile phone wallet. You can get the components as individual parts and build the case yourself e.g. from <a href="https://www.Fulmo.org">Fulmo</a> who also made a <a href="https://blog.fulmo.org/the-lightningatm-pocket-edition/">guide</a> on it. The shop offers payments in Bitcoin and Lightning ⚡. The code can be found on <a href="https://github.com/21isenough/LightningATM">the ATM github project page></a>.</p>
</details>
<details><summary>POS Terminal - an offline terminal for merchants</summary>
<p>The LNpos is a self-sufficient point of sale terminal which allows offline onchain payments and an offline Lightning ATM for withdrawals. Free and open source software, free from intermediaries, with integrated battery, WLAN, DIY. You can get the 3D print as well as the whole kit from the LNbits shop from 👇 Resources. It allows
<li>LNPoS Online interactive Lightning payments</li>
@@ -183,21 +183,21 @@ allow-self-payment=1
</p>
</p>
</details>
<details><summary>Hardware Wallet- build your own, stack harder</summary>
<p>The hardwarewallet is a very cheap solution for builders. The projects´ <a hrel="https://github.com/lnbits/hardware-wallet">code and installation instructions for the LNbits hardware wallet can be found on github</a></p>
<p>
<img width="546" alt="Bildschirm­foto 2023-01-20 um 18 08 37" src="https://user-images.githubusercontent.com/63317640/213760948-38fd77b0-9247-4505-9433-f5af1b223527.png">
</p>
</details>
<details><summary>Bitcoin Switch - turn things on with bitcoin</summary>
<p>Candy dispenser, vending machines (online), grabbing machines, jukeboxes, bandits and <a href="https://github.com/cryptoteun/awesome-lnbits">all sorts of other things have already been build with LNbits´ tools</a>. Further info see 👇 Resources.</p>
<p>
<img width="549" alt="Bildschirm­foto 2023-01-20 um 18 11 55" src="https://user-images.githubusercontent.com/63317640/213761646-d25d4745-e50d-4389-98e5-f83237a8cf6b.png">
</p>
</details>
<details><summary>Vending machine (offline)</summary>
<p>This code works similar to the LNpos. Note that the <a href=" https://www.youtube.com/watch?v=Fg0UuuzsYXc&t=762s">setup-video for the vending machine</a> misses the new way of installing it via the new LNURLdevices extension. The <a href="https://github.com/arcbtc/LNURLVend">vending machine project code resides on github</a>.</p>
<p>
@@ -262,7 +262,7 @@ allow-self-payment=1
</ul>
## Developing for LNbits
<ul>
<ul>
<li><a href="http://docs.lnbits.org/devs/development.html">Making Estension / how to use Websockets / API reference</a></li>
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
</ul>
+6 -6
View File
@@ -2,7 +2,7 @@
## Defining a route with path parameters
**old:**
```python
# with <>
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
@@ -35,8 +35,8 @@ async def extensions(user: User = Depends(check_user_exists)):
```python
return (
{
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
},
HTTPStatus.OK,
@@ -47,8 +47,8 @@ FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:**
```python
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
}
```
@@ -119,4 +119,4 @@ Instead of hitting the database over and over again, we can store a short lived
Example:
* Get transactions for a wallet ID
* User data for a user id
* Wallet data for a Admin / Invoice key
* Wallet data for a Admin / Invoice key
+8 -2
View File
@@ -18,6 +18,12 @@ A backend wallet can be configured using the following LNbits environment variab
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
### CoreLightning REST
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
### Spark (Core Lightning)
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
@@ -29,7 +35,7 @@ A backend wallet can be configured using the following LNbits environment variab
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
or
@@ -41,7 +47,7 @@ or
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
You can also use an AES-encrypted macaroon (more info) instead by using
+1 -1
View File
@@ -11,7 +11,7 @@ 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
* 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#F8F8F8"/>
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#F8F8F8"/>
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.283 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.19 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="white"/>
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="white"/>
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.547 2565.55 705.547C2573.28 705.547 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.49 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="white"/>
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.4 391.781 2764.18 408.231 2764.18 428.214C2764.18 445.976 2770.53 460.004 2783.22 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.08C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+11 -1
View File
@@ -1 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#1E1E1E"/>
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#1E1E1E"/>
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.282 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.2 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="black"/>
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="black"/>
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.546 2565.55 705.546C2573.28 705.546 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.5 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="black"/>
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.41 391.781 2764.18 408.231 2764.18 428.213C2764.18 445.976 2770.53 460.004 2783.23 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.079C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+19 -1
View File
@@ -1 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g></svg>
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_10_29)">
<circle cx="450" cy="450" r="450" fill="#1E1E1E"/>
</g>
<path d="M387.802 476.423L281.613 730.887L584.54 389.763H459.418L619.113 168.387H407.558L305.485 476.423H387.802Z" fill="#FF1FE1"/>
<defs>
<filter id="filter0_i_10_29" x="-0.1" y="-0.1" width="900.1" height="900.1" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.6" operator="dilate" in="SourceAlpha" result="effect1_innerShadow_10_29"/>
<feOffset dx="-1" dy="-0.4"/>
<feGaussianBlur stdDeviation="0.35"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.121569 0 0 0 0 0.882353 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_10_29"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Generated
+46 -6
View File
@@ -1,12 +1,15 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
@@ -15,6 +18,27 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1678470307,
@@ -34,16 +58,17 @@
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1678513806,
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
"lastModified": 1695386222,
"narHash": "sha256-5lgnhCCGW0NH5+m5iTED8u6NSSM/dbH9LBPvX0x0XXg=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
"rev": "093383b3d7fdd36846a7d84e128ca11865800538",
"type": "github"
},
"original": {
@@ -57,6 +82,21 @@
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
+17 -1
View File
@@ -28,13 +28,29 @@
});
overlays = {
default = final: prev: {
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
};
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.${projectName};
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
meta.rev = self.dirtyRev or self.rev;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
ruff = prev.ruff.override { preferWheel = true; };
fastapi = prev.fastapi.overridePythonAttrs (old: {
postPatch = ''
substituteInPlace pyproject.toml \
--replace '"Framework :: Pydantic",' "" \
--replace '"Framework :: Pydantic :: 1",' ""
'';
});
bolt11 = prev.bolt11.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.poetry
];
});
});
};
});
nixosModules = {
+186 -76
View File
@@ -7,37 +7,49 @@ import shutil
import signal
import sys
import traceback
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path
from typing import Callable, List
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, RequestValidationError
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.responses import JSONResponse
from lnbits.core.crud import get_installed_extensions
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import register_task_listeners
from lnbits.settings import get_wallet_class, set_wallet_class, settings
from lnbits.core.services import websocketUpdater
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
register_killswitch,
register_task_listeners,
)
from lnbits.settings import settings
from lnbits.tasks import cancel_all_tasks, create_permanent_task
from lnbits.utils.cache import cache
from lnbits.wallets import get_wallet_class, set_wallet_class
from .commands import db_versions, load_disabled_extension_list, migrate_databases
from .core import (
add_installed_extension,
core_app,
core_app_extra,
update_installed_extension_state,
)
from .core.services import check_admin_settings
from .core.views.generic import core_html_routes
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.generic import update_installed_extension_state
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
from .helpers import template_renderer
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
from .middleware import (
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
add_ip_block_middleware,
add_ratelimit_middleware,
)
from .requestvars import g
from .tasks import (
catch_everything_and_restart,
check_pending_payments,
internal_invoice_listener,
invoice_listener,
@@ -46,12 +58,13 @@ from .tasks import (
def create_app() -> FastAPI:
configure_logger()
app = FastAPI(
title="LNbits API",
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
title=settings.lnbits_title,
description=(
"API for LNbits, the free and open source bitcoin wallet and "
"accounts system with plugins."
),
version=settings.version,
license_info={
"name": "MIT License",
@@ -59,6 +72,10 @@ 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))
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
app.mount(
"/core/static",
@@ -72,29 +89,36 @@ def create_app() -> FastAPI:
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
# order of these two middlewares is important
app.add_middleware(InstalledExtensionMiddleware)
app.add_middleware(ExtensionsRedirectMiddleware)
register_custom_extensions_path()
# adds security middleware
add_ip_block_middleware(app)
add_ratelimit_middleware(app)
register_startup(app)
register_routes(app)
register_async_tasks(app)
register_exception_handlers(app)
# 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))
register_shutdown(app)
return app
async def check_funding_source() -> None:
original_sigint_handler = signal.getsignal(signal.SIGINT)
def signal_handler(signal, frame):
logger.debug("SIGINT received, terminating LNbits.")
logger.debug(
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
)
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
@@ -116,17 +140,16 @@ async def check_funding_source() -> None:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
f"The backend for {WALLET.__class__.__name__} isn't "
f"working properly: '{error_message}'",
RuntimeWarning,
)
except:
except Exception as e:
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
pass
if settings.lnbits_admin_ui and retry_counter == timeout:
logger.warning(
f"Fallback to VoidWallet, because the backend for {WALLET.__class__.__name__} isn't working properly"
)
set_wallet_class("VoidWallet")
set_void_wallet_class()
WALLET = get_wallet_class()
break
else:
@@ -137,16 +160,25 @@ async def check_funding_source() -> None:
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
f"✔️ Backend {WALLET.__class__.__name__} connected "
f"and with a balance of {balance} msat."
)
def set_void_wallet_class():
logger.warning(
"Fallback to VoidWallet, because the backend for "
f"{settings.lnbits_backend_wallet_class} isn't working properly"
)
set_wallet_class("VoidWallet")
async def check_installed_extensions(app: FastAPI):
"""
Check extensions that have been installed, but for some reason no longer present in the 'lnbits/extensions' directory.
One reason might be a docker-container that was re-created.
The 'data' directory (where the '.zip' files live) is expected to persist state.
Zips that are missing will be re-downloaded.
Check extensions that have been installed, but for some reason no longer present in
the 'lnbits/extensions' directory. One reason might be a docker-container that was
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
await load_disabled_extension_list()
@@ -154,11 +186,12 @@ async def check_installed_extensions(app: FastAPI):
for ext in installed_extensions:
try:
installed = check_installed_extension(ext)
installed = check_installed_extension_files(ext)
if not installed:
await restore_installed_extension(app, ext)
logger.info(
f"✔️ Successfully re-installed extension: {ext.id} ({ext.installed_version})"
"✔️ Successfully re-installed extension: "
f"{ext.id} ({ext.installed_version})"
)
except Exception as e:
logger.warning(e)
@@ -166,6 +199,10 @@ async def check_installed_extensions(app: FastAPI):
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
logger.info(f"Installed Extensions ({len(installed_extensions)}):")
for ext in installed_extensions:
logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
"""
@@ -187,19 +224,15 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
)
installed_extensions.append(ext_info)
await add_installed_extension(ext_info)
await update_installed_extension_state(ext_id=ext_id, active=True)
return installed_extensions
def check_installed_extension(ext: InstallableExtension) -> bool:
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, "extensions", "*.zip")
)
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()
@@ -209,6 +242,9 @@ def check_installed_extension(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
@@ -223,28 +259,57 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions."""
app.include_router(core_app)
app.include_router(core_html_routes)
init_core_routers(app)
for ext in get_valid_extensions():
try:
register_ext_routes(app, ext)
except Exception as e:
logger.error(str(e))
raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions."
)
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
def register_custom_extensions_path():
if settings.has_default_extension_path:
return
default_ext_path = os.path.join("lnbits", "extensions")
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
logger.warning(
"You are using a custom extensions path, "
+ "but the default extensions directory is not empty. "
+ f"Please clean-up the '{default_ext_path}' directory."
)
logger.warning(
f"You can move the existing '{default_ext_path}' directory to: "
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
def register_new_ext_routes(app: FastAPI) -> Callable:
# Returns a function that registers new routes for an extension.
# The returned function encapsulates (creates a closure around) the `app` object but does expose it.
# The returned function encapsulates (creates a closure around)
# the `app` object but does expose it.
def register_new_ext_routes_fn(ext: Extension):
register_ext_routes(app, ext)
return register_new_ext_routes_fn
def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_new_ratelimiter_fn():
limiter = Limiter(
key_func=get_remote_address,
default_limits=[
f"{settings.lnbits_rate_limit_no}/{settings.lnbits_rate_limit_unit}"
],
)
app.state.limiter = limiter
return register_new_ratelimiter_fn
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
ext_module = importlib.import_module(ext.module_name)
@@ -258,7 +323,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
static_dir = Path(
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
@@ -278,18 +346,25 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
def register_startup(app: FastAPI):
@app.on_event("startup")
async def lnbits_startup():
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
set_wallet_class()
try:
set_wallet_class()
except Exception as e:
logger.error(
f"Error initializing {settings.lnbits_backend_wallet_class}: "
f"{str(e)}"
)
set_void_wallet_class()
# initialize funding source
await check_funding_source()
@@ -297,11 +372,42 @@ def register_startup(app: FastAPI):
# check extensions after restart
await check_installed_extensions(app)
if settings.lnbits_admin_ui:
initialize_server_logger()
except Exception as e:
logger.error(str(e))
raise ImportError("Failed to run 'startup' event.")
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 initialize_server_logger():
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
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}")
@@ -312,7 +418,6 @@ def log_server_info():
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"Git version: {settings.lnbits_commit}")
logger.info(f"Database: {get_db_vendor_name()}")
logger.info(f"Service fee: {settings.lnbits_service_fee}")
@@ -322,9 +427,11 @@ def get_db_vendor_name():
return (
"PostgreSQL"
if db_url and db_url.startswith("postgres://")
else "CockroachDB"
if db_url and db_url.startswith("cockroachdb://")
else "SQLite"
else (
"CockroachDB"
if db_url and db_url.startswith("cockroachdb://")
else "SQLite"
)
)
@@ -335,17 +442,14 @@ def register_async_tasks(app):
@app.on_event("startup")
async def listeners():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(check_pending_payments))
loop.create_task(catch_everything_and_restart(invoice_listener))
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
await register_task_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?
@app.on_event("shutdown")
async def stop_listeners():
pass
def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
@@ -421,26 +525,32 @@ def configure_logger() -> None:
logger.remove()
log_level: str = "DEBUG" if settings.debug else "INFO"
formatter = Formatter()
logger.add(sys.stderr, level=log_level, format=formatter.format)
logger.add(sys.stdout, level=log_level, 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: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
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: str = (
"<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"
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: str = self.minimal_fmt
self.fmt = self.minimal_fmt
def format(self, record):
function = "{function}".format(**record) # pylint: disable=C0209
function = "{function}".format(**record)
if function == "emit": # uvicorn logs
return self.minimal_fmt
return self.fmt
+7 -366
View File
@@ -1,366 +1,7 @@
import hashlib
import re
import time
from decimal import Decimal
from typing import List, NamedTuple, Optional
import bitstring
import secp256k1
from bech32 import CHARSET, bech32_decode, bech32_encode
from ecdsa import SECP256k1, VerifyingKey
from ecdsa.util import sigdecode_string
class Route(NamedTuple):
pubkey: str
short_channel_id: str
base_fee_msat: int
ppm_fee: int
cltv: int
class Invoice:
payment_hash: str
amount_msat: int = 0
description: Optional[str] = None
description_hash: Optional[str] = None
payee: Optional[str] = None
date: int
expiry: int = 3600
secret: Optional[str] = None
route_hints: List[Route] = []
min_final_cltv_expiry: int = 18
def decode(pr: str) -> Invoice:
"""bolt11 decoder,
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
"""
hrp, decoded_data = bech32_decode(pr)
if hrp is None or decoded_data is None:
raise ValueError("Bad bech32 checksum")
if not hrp.startswith("ln"):
raise ValueError("Does not start with ln")
bitarray = _u5_to_bitarray(decoded_data)
# final signature 65 bytes, split it off.
if len(bitarray) < 65 * 8:
raise ValueError("Too short to contain signature")
# extract the signature
signature = bitarray[-65 * 8 :].tobytes()
# the tagged fields as a bitstream
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
# build the invoice object
invoice = Invoice()
# decode the amount from the hrp
m = re.search(r"[^\d]+", hrp[2:])
if m:
amountstr = hrp[2 + m.end() :]
if amountstr != "":
invoice.amount_msat = _unshorten_amount(amountstr)
# pull out date
date_bin = data.read(35)
invoice.date = date_bin.uint # type: ignore
while data.pos != data.len:
tag, tagdata, data = _pull_tagged(data)
data_length = len(tagdata or []) / 5
if tag == "d":
invoice.description = _trim_to_bytes(tagdata).decode()
elif tag == "h" and data_length == 52:
invoice.description_hash = _trim_to_bytes(tagdata).hex()
elif tag == "p" and data_length == 52:
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
elif tag == "x":
invoice.expiry = tagdata.uint # type: ignore
elif tag == "n":
invoice.payee = _trim_to_bytes(tagdata).hex()
# this won't work in most cases, we must extract the payee
# from the signature
elif tag == "s":
invoice.secret = _trim_to_bytes(tagdata).hex()
elif tag == "r":
s = bitstring.ConstBitStream(tagdata)
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
route = Route(
pubkey=s.read(264).tobytes().hex(), # type: ignore
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
base_fee_msat=s.read(32).intbe, # type: ignore
ppm_fee=s.read(32).intbe, # type: ignore
cltv=s.read(16).intbe, # type: ignore
)
invoice.route_hints.append(route)
# BOLT #11:
# A reader MUST check that the `signature` is valid (see the `n` tagged
# field specified below).
# A reader MUST use the `n` field to validate the signature instead of
# performing signature recovery if a valid `n` field is provided.
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
sig = signature[0:64]
if invoice.payee:
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(
sig, message, SECP256k1, hashlib.sha256
)
signaling_byte = signature[64]
key = keys[int(signaling_byte)]
invoice.payee = key.to_string("compressed").hex()
return invoice
def encode(options):
"""Convert options into LnAddr and pass it to the encoder"""
addr = LnAddr()
addr.currency = options["currency"]
addr.fallback = options["fallback"] if options["fallback"] else None
if options["amount"]:
addr.amount = options["amount"]
if options["timestamp"]:
addr.date = int(options["timestamp"])
addr.paymenthash = bytes.fromhex(options["paymenthash"])
if options["description"]:
addr.tags.append(("d", options["description"]))
if options["description_hash"]:
addr.tags.append(("h", options["description_hash"]))
if options["expires"]:
addr.tags.append(("x", options["expires"]))
if options["fallback"]:
addr.tags.append(("f", options["fallback"]))
if options["route"]:
for r in options["route"]:
splits = r.split("/")
route = []
while len(splits) >= 5:
route.append(
(
bytes.fromhex(splits[0]),
bytes.fromhex(splits[1]),
int(splits[2]),
int(splits[3]),
int(splits[4]),
)
)
splits = splits[5:]
assert len(splits) == 0
addr.tags.append(("r", route))
return lnencode(addr, options["privkey"])
def lnencode(addr, privkey):
if addr.amount:
amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi.
if amount * 10**12 % 10:
raise ValueError(f"Cannot encode {addr.amount}: too many decimal places")
amount = addr.currency + shorten_amount(amount)
else:
amount = addr.currency if addr.currency else ""
hrp = f"ln{amount}0n"
# Start with the timestamp
data = bitstring.pack("uint:35", addr.date)
# Payment hash
data += tagged_bytes("p", addr.paymenthash)
tags_set = set()
for k, v in addr.tags:
# BOLT #11:
#
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
if k in ("d", "h", "n", "x"):
if k in tags_set:
raise ValueError(f"Duplicate '{k}' tag")
if k == "r":
route = bitstring.BitArray()
for step in v:
pubkey, channel, feebase, feerate, cltv = step
route.append(
bitstring.BitArray(pubkey)
+ bitstring.BitArray(channel)
+ bitstring.pack("intbe:32", feebase)
+ bitstring.pack("intbe:32", feerate)
+ bitstring.pack("intbe:16", cltv)
)
data += tagged("r", route)
elif k == "f":
# NOTE: there was an error fallback here that's now removed
continue
elif k == "d":
data += tagged_bytes("d", v.encode())
elif k == "x":
# Get minimal length by trimming leading 5 bits at a time.
expirybits = bitstring.pack("intbe:64", v)[4:64]
while expirybits.startswith("0b00000"):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", v)
elif k == "n":
data += tagged_bytes("n", v)
else:
# FIXME: Support unknown tags?
raise ValueError(f"Unknown tag {k}")
tags_set.add(k)
# BOLT #11:
#
# A writer MUST include either a `d` or `h` field, and MUST NOT include
# both.
if "d" in tags_set and "h" in tags_set:
raise ValueError("Cannot include both 'd' and 'h'")
if "d" not in tags_set and "h" not in tags_set:
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
# This doesn't actually serialize, but returns a pair of values :(
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
data += bytes(sig) + bytes([recid])
return bech32_encode(hrp, bitarray_to_u5(data))
class LnAddr:
def __init__(
self,
paymenthash=None,
amount=None,
currency="bc",
tags=None,
date=None,
fallback=None,
):
self.date = int(time.time()) if not date else int(date)
self.tags = [] if not tags else tags
self.unknown_tags = []
self.paymenthash = paymenthash
self.signature = None
self.pubkey = None
self.fallback = fallback
self.currency = currency
self.amount = amount
def __str__(self):
assert self.pubkey, "LnAddr, pubkey must be set"
pubkey = bytes.hex(self.pubkey.serialize())
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
def shorten_amount(amount):
"""Given an amount in bitcoin, shorten it"""
# Convert to pico initially
amount = int(amount * 10**12)
units = ["p", "n", "u", "m", ""]
unit = ""
for unit in units:
if amount % 1000 == 0:
amount //= 1000
else:
break
return str(amount) + unit
def _unshorten_amount(amount: str) -> int:
"""Given a shortened amount, return millisatoshis"""
# BOLT #11:
# The following `multiplier` letters are defined:
#
# * `m` (milli): multiply by 0.001
# * `u` (micro): multiply by 0.000001
# * `n` (nano): multiply by 0.000000001
# * `p` (pico): multiply by 0.000000000001
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
unit = str(amount)[-1]
# BOLT #11:
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
# anything except a `multiplier` in the table above.
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
raise ValueError(f"Invalid amount '{amount}'")
if unit in units:
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
else:
return int(amount) * 100_000_000_000
def _pull_tagged(stream):
tag = stream.read(5).uint
length = stream.read(5).uint * 32 + stream.read(5).uint
return (CHARSET[tag], stream.read(length * 5), stream)
# Tagged field containing BitArray
def tagged(char, bits):
# Tagged fields need to be zero-padded to 5 bits.
while bits.len % 5 != 0:
bits.append("0b0")
return (
bitstring.pack(
"uint:5, uint:5, uint:5",
CHARSET.find(char),
(bits.len / 5) / 32,
(bits.len / 5) % 32,
)
+ bits
)
def tagged_bytes(char, bits):
return tagged(char, bitstring.BitArray(bits))
def _trim_to_bytes(barr):
# Adds a byte if necessary.
b = barr.tobytes()
if barr.len % 8 != 0:
return b[:-1]
return b
def _readable_scid(short_channel_id: int) -> str:
blockheight = (short_channel_id >> 40) & 0xFFFFFF
transactionindex = (short_channel_id >> 16) & 0xFFFFFF
outputindex = short_channel_id & 0xFFFF
return f"{blockheight}x{transactionindex}x{outputindex}"
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
ret = bitstring.BitArray()
for a in arr:
ret += bitstring.pack("uint:5", a)
return ret
def bitarray_to_u5(barr):
assert barr.len % 5 == 0
ret = []
s = bitstring.ConstBitStream(barr)
while s.pos != s.len:
ret.append(s.read(5).uint) # type: ignore
return ret
from bolt11 import (
Bolt11 as Invoice, # noqa: F401
)
from bolt11 import (
decode, # noqa: F401
encode, # noqa: F401
)
+77 -6
View File
@@ -1,4 +1,5 @@
import asyncio
from pathlib import Path
import click
from loguru import logger
@@ -13,8 +14,58 @@ from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import get_valid_extensions
@click.command("migrate")
def db_migrate():
@click.group()
def lnbits_cli():
"""
Python CLI for LNbits
"""
@lnbits_cli.group()
def db():
"""
Database related commands
"""
def get_super_user() -> str:
"""Get the superuser"""
with open(Path(settings.lnbits_data_folder) / ".super_user", "r") as file:
return file.readline()
@lnbits_cli.command("superuser")
def superuser():
"""Prints the superuser"""
click.echo(get_super_user())
@lnbits_cli.command("superuser-url")
def superuser_url():
"""Prints the superuser"""
click.echo(f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}")
@lnbits_cli.command("delete-settings")
def delete_settings():
"""Deletes the settings"""
async def wrap():
async with core_db.connect() as conn:
await conn.execute("DELETE from settings")
loop = asyncio.get_event_loop()
loop.run_until_complete(wrap())
@db.command("migrate")
def database_migrate():
"""Migrate databases"""
loop = asyncio.get_event_loop()
loop.run_until_complete(migrate_databases())
async def db_migrate():
asyncio.create_task(migrate_databases())
@@ -29,7 +80,8 @@ async def migrate_databases():
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbversions'"
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
@@ -41,18 +93,37 @@ async def migrate_databases():
for ext in get_valid_extensions():
current_version = current_versions.get(ext.code, 0)
await migrate_extension_database(ext, current_version)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions")
def database_versions():
"""Show current database versions"""
loop = asyncio.get_event_loop()
loop.run_until_complete(db_versions())
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
current_versions = await get_dbversions(conn)
return current_versions
return await get_dbversions(conn)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions()
settings.lnbits_deactivated_extensions += inactive_extensions
def main():
"""main function"""
lnbits_cli()
if __name__ == "__main__":
main()
+19 -10
View File
@@ -1,15 +1,24 @@
from fastapi.routing import APIRouter
from fastapi import APIRouter
from lnbits.core.models import CoreAppExtra
from lnbits.db import Database
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
db = Database("database")
# 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.public_api import public_router
core_app: APIRouter = APIRouter()
# backwards compatibility for extensions
core_app = APIRouter(tags=["Core"])
core_app_extra: CoreAppExtra = CoreAppExtra()
from .views.admin_api import * # noqa: F401,F403
from .views.api import * # noqa: F401,F403
from .views.generic import * # noqa: F401,F403
from .views.public_api import * # noqa: F401,F403
def init_core_routers(app):
app.include_router(core_app)
app.include_router(generic_router)
app.include_router(public_router)
app.include_router(api_router)
app.include_router(node_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(admin_router)
+274 -50
View File
@@ -1,18 +1,34 @@
import datetime
import json
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse
from uuid import UUID, uuid4
import shortuuid
from bolt11.decode import decode
from lnbits import bolt11
from lnbits.db import Connection, Filters, Page
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 AdminSettings, EditableSettings, SuperSettings, settings
from lnbits.settings import (
AdminSettings,
EditableSettings,
SuperSettings,
WebPushSettings,
settings,
)
from . import db
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
from .models import (
BalanceCheck,
Payment,
PaymentFilters,
PaymentHistoryPoint,
TinyURL,
User,
Wallet,
WebPushSubscription,
)
# accounts
# --------
@@ -57,9 +73,11 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
)
wallets = await (conn or db).fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet = wallets.id
), 0) AS balance_msat
FROM wallets
WHERE "user" = ?
WHERE "user" = ? and wallets.deleted = false
""",
(user_id,),
)
@@ -75,6 +93,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
wallets=[Wallet(**w) for w in wallets],
admin=user["id"] == settings.super_user
or user["id"] in settings.lnbits_admin_users,
super_user=user["id"] == settings.super_user,
)
@@ -87,9 +106,9 @@ async def add_installed_extension(
conn: Optional[Connection] = None,
) -> None:
meta = {
"installed_release": dict(ext.installed_release)
if ext.installed_release
else None,
"installed_release": (
dict(ext.installed_release) if ext.installed_release else None
),
"dependencies": ext.dependencies,
}
@@ -97,9 +116,11 @@ async def add_installed_extension(
await (conn or db).execute(
"""
INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO
UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?)
INSERT INTO installed_extensions
(id, version, name, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(version, name, active, short_description, icon, stars, meta) =
(?, ?, ?, ?, ?, ?, ?)
""",
(
ext.id,
@@ -142,6 +163,25 @@ async def delete_installed_extension(
)
async def drop_extension_db(*, ext_id: str, conn: Optional[Connection] = None) -> None:
db_version = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = ?", (ext_id,)
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert db_version, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
(),
)
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
row = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = ?",
@@ -213,15 +253,25 @@ async def create_wallet(
async def update_wallet(
wallet_id: str, new_name: str, conn: Optional[Connection] = None
wallet_id: str,
name: Optional[str] = None,
currency: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
set_clause = []
values = []
if name:
set_clause.append("name = ?")
values.append(name)
if currency is not None:
set_clause.append("currency = ?")
values.append(currency)
values.append(wallet_id)
await (conn or db).execute(
"""
UPDATE wallets SET
name = ?
WHERE id = ?
f"""
UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
""",
(new_name, wallet_id),
tuple(values),
)
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
assert wallet, "updated created wallet couldn't be retrieved"
@@ -233,11 +283,8 @@ async def delete_wallet(
) -> None:
await (conn or db).execute(
"""
UPDATE wallets AS w
SET
"user" = 'del:' || w."user",
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
UPDATE wallets
SET deleted = true
WHERE id = ? AND "user" = ?
""",
(wallet_id, user_id),
@@ -249,9 +296,8 @@ async def get_wallet(
) -> 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 id = ?
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
AS balance_msat FROM wallets WHERE id = ?
""",
(wallet_id,),
)
@@ -260,13 +306,14 @@ async def get_wallet(
async def get_wallet_for_key(
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
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 = ?
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
""",
(key, key),
)
@@ -274,7 +321,7 @@ async def get_wallet_for_key(
if not row:
return None
if key_type == "admin" and row["adminkey"] != key:
if key_type == WalletType.admin and row["adminkey"] != key:
return None
return Wallet(**row)
@@ -285,6 +332,11 @@ 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
# ---------------
@@ -488,14 +540,16 @@ async def create_payment(
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Payment:
# todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists"
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniquess constarints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
try:
invoice = bolt11.decode(payment_request)
invoice = decode(payment_request)
if invoice.expiry:
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
except:
else:
# assume maximum bolt11 expiry of 31 days to be on the safe side
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
@@ -516,9 +570,11 @@ async def create_payment(
pending,
memo,
fee,
json.dumps(extra)
if extra and extra != {} and type(extra) is dict
else None,
(
json.dumps(extra)
if extra and extra != {} and isinstance(extra, dict)
else None
),
webhook,
db.datetime_to_timestamp(expiration_date),
),
@@ -580,7 +636,8 @@ async def update_payment_extra(
) -> None:
"""
Only update the `extra` field for the payment.
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
Old values in the `extra` JSON object will be kept
unless the new `extra` overwrites them.
"""
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
@@ -600,10 +657,77 @@ async def update_payment_extra(
)
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
"day": "%Y-%m-%d 00:00:00",
"month": "%Y-%m-01 00:00:00",
}
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> List[PaymentHistoryPoint]:
if not filters:
filters = Filters()
where = ["(pending = False OR amount < 0)"]
values = []
if wallet_id:
where.append("wallet = ?")
values.append(wallet_id)
if DB_TYPE == SQLITE and group in sqlite_formats:
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
elif group in ("day", "hour", "month"):
date_trunc = f"date_trunc('{group}', time)"
else:
raise ValueError(f"Invalid group value: {group}")
transactions = await db.fetchall(
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""",
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance, date=row[0], income=row[1], spending=row[2]
),
)
balance -= row.income - row.spending
return results
async def delete_wallet_payment(
@@ -618,6 +742,10 @@ async def delete_wallet_payment(
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[str]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
row = await (conn or db).fetchone(
"""
SELECT checking_id FROM apipayments
@@ -634,7 +762,10 @@ async def check_internal(
async def check_internal_pending(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""Returns False if the internal payment is not pending anymore (and thus paid), otherwise True"""
"""
Returns False if the internal payment is not pending anymore
(and thus paid), otherwise True
"""
row = await (conn or db).fetchone(
"""
SELECT pending FROM apipayments
@@ -741,12 +872,17 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
return admin_settings
async def delete_admin_settings():
async def delete_admin_settings() -> None:
await db.execute("DELETE FROM settings")
async def update_admin_settings(data: EditableSettings):
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
async def update_admin_settings(data: EditableSettings) -> None:
row = await db.fetchone("SELECT editable_settings FROM settings")
editable_settings = json.loads(row["editable_settings"]) if row else {}
editable_settings.update(data.dict(exclude_unset=True))
await db.execute(
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
)
async def update_super_user(super_user: str) -> SuperSettings:
@@ -781,6 +917,15 @@ async def update_migration_version(conn, db_name, version):
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = ?
""",
(ext_id,),
)
# tinyurl
# -------
@@ -820,3 +965,82 @@ async def delete_tinyurl(tinyurl_id: str):
"DELETE FROM tiny_url WHERE id = ?",
(tinyurl_id,),
)
# push_notification
# -----------------
async def get_webpush_settings() -> Optional[WebPushSettings]:
row = await db.fetchone("SELECT * FROM webpush_settings")
if not row:
return None
vapid_keypair = json.loads(row["vapid_keypair"])
return WebPushSettings(**vapid_keypair)
async def create_webpush_settings(webpush_settings: dict):
await db.execute(
"INSERT INTO webpush_settings (vapid_keypair) VALUES (?)",
(json.dumps(webpush_settings),),
)
return await get_webpush_settings()
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
row = await db.fetchone(
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
(
endpoint,
user,
),
)
return WebPushSubscription(**dict(row)) if row else None
async def get_webpush_subscriptions_for_user(
user: str,
) -> List[WebPushSubscription]:
rows = await db.fetchall(
"SELECT * FROM webpush_subscriptions WHERE user = ?",
(user,),
)
return [WebPushSubscription(**dict(row)) for row in rows]
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
VALUES (?, ?, ?, ?)
""",
(
endpoint,
user,
data,
host,
),
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
(
endpoint,
user,
),
)
async def delete_webpush_subscriptions(endpoint: str) -> None:
await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
)
+5
View File
@@ -0,0 +1,5 @@
from lnbits.core.models import CoreAppExtra
from lnbits.db import Database
db = Database("database")
core_app_extra: CoreAppExtra = CoreAppExtra()
+4 -3
View File
@@ -6,11 +6,11 @@ from uuid import UUID
import httpx
from loguru import logger
from lnbits.core.db import db as core_db
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.settings import settings
from . import db as core_db
from .crud import update_migration_version
@@ -51,7 +51,8 @@ async def stop_extension_background_work(ext_id: str, user: str):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
This function tries first to call the endpoint using `http` and if if fails it tries using `https`.
This function tries first to call the endpoint using `http`
and if it fails it tries using `https`.
"""
async with httpx.AsyncClient() as client:
try:
@@ -71,7 +72,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:
except Exception:
raise ValueError("Invalid hex string for User ID.")
return UUID(hex=user_id[:32], version=4)
+94 -2
View File
@@ -239,7 +239,8 @@ async def m007_set_invoice_expiries(db):
invoice.date + invoice.expiry
)
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
@@ -251,7 +252,7 @@ async def m007_set_invoice_expiries(db):
checking_id,
),
)
except:
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -301,3 +302,94 @@ async def m010_create_installed_extensions_table(db):
);
"""
)
async def m011_optimize_balances_view(db):
"""
Make the calculation of the balance a single aggregation
over the payments table instead of 2.
"""
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, SUM(amount - abs(fee)) AS balance
FROM apipayments
WHERE (pending = false AND amount > 0) OR amount < 0
GROUP BY wallet
"""
)
async def m012_add_currency_to_wallet(db):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN currency TEXT
"""
)
async def m013_add_deleted_to_wallets(db):
"""
Adds deleted column to wallets.
"""
try:
await db.execute(
"ALTER TABLE wallets ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT false"
)
except OperationalError:
pass
async def m014_set_deleted_wallets(db):
"""
Sets deleted column to wallets.
"""
try:
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
for row in rows:
try:
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
""",
(user, adminkey, inkey, row[0]),
)
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
# run twice in some environments.
# catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice.
pass
async def m015_create_push_notification_subscriptions_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
endpoint TEXT NOT NULL,
"user" TEXT NOT NULL,
data TEXT NOT NULL,
host TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
PRIMARY KEY (endpoint, "user")
);
"""
)
+105 -8
View File
@@ -3,17 +3,21 @@ import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from lnurl import encode as lnurl_encode
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.settings import get_wallet_class, settings
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class
from lnbits.wallets.base import PaymentStatus
@@ -23,7 +27,9 @@ class Wallet(BaseModel):
user: str
adminkey: str
inkey: str
currency: Optional[str]
balance_msat: int
deleted: bool
@property
def balance(self) -> int:
@@ -37,11 +43,10 @@ class Wallet(BaseModel):
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except:
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
@@ -58,6 +63,22 @@ class Wallet(BaseModel):
return await get_standalone_payment(payment_hash)
class WalletType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
wallet_type: WalletType
wallet: Wallet
class User(BaseModel):
id: str
email: Optional[str] = None
@@ -169,6 +190,8 @@ class Payment(FromRowModel):
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
@@ -179,7 +202,8 @@ class Payment(FromRowModel):
return PaymentStatus(None)
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
WALLET = get_wallet_class()
@@ -193,7 +217,8 @@ class Payment(FromRowModel):
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: expired {expiration_date}"
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
elif self.is_out and status.failed:
@@ -203,15 +228,16 @@ class Payment(FromRowModel):
await self.delete(conn)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_payment
from .crud import delete_wallet_payment
await delete_payment(self.checking_id, conn=conn)
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
@@ -232,6 +258,13 @@ class PaymentFilters(FilterModel):
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
class BalanceCheck(BaseModel):
wallet: str
service: str
@@ -244,6 +277,7 @@ class BalanceCheck(BaseModel):
class CoreAppExtra:
register_new_ext_routes: Callable
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
@@ -256,3 +290,66 @@ class TinyURL(BaseModel):
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str
+164 -47
View File
@@ -1,31 +1,35 @@
import asyncio
import json
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
import httpx
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.helpers import url_for
from lnbits.settings import (
FAKE_WALLET,
EditableSettings,
SuperSettings,
get_wallet_class,
readonly_variables,
send_admin_user_to_saas,
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db
from .crud import (
check_internal,
check_internal_pending,
@@ -37,14 +41,16 @@ from .crud import (
get_account,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
)
from .helpers import to_valid_user_id
from .models import Payment
from .models import Payment, Wallet
class PaymentFailure(Exception):
@@ -55,10 +61,49 @@ class InvoiceFailure(Exception):
pass
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[Dict] = None,
conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, extra
async def create_invoice(
*,
wallet_id: str,
amount: int, # in satoshis
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
@@ -71,24 +116,31 @@ async def create_invoice(
if not amount > 0:
raise InvoiceFailure("Amountless invoices not supported.")
if await get_wallet(wallet_id, conn=conn) is None:
raise InvoiceFailure("Wallet does not exist.")
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()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount,
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok:
if not ok or not payment_request or not checking_id:
raise InvoiceFailure(error_message or "unexpected backend error.")
invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000
amount_msat = 1000 * amount_sat
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
@@ -115,16 +167,23 @@ async def pay_invoice(
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve fee.
We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend.
If the payment is successful, we update the payment in the database with the payment details.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process will regularly check for the payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
invoice = bolt11.decode(payment_request)
if not invoice.amount_msat or not 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.")
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
@@ -133,6 +192,10 @@ async def pay_invoice(
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
)
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
@@ -151,11 +214,13 @@ async def pay_invoice(
extra=extra,
)
# we check if an internal invoice exists that has already been paid (not pending anymore)
# 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.")
# check_internal() returns the checking_id of the invoice we're waiting for (pending only)
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
# perform additional checks on the internal payment
@@ -172,7 +237,7 @@ async def pay_invoice(
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
await create_payment(
new_payment = await create_payment(
checking_id=internal_id,
fee=0,
pending=False,
@@ -184,7 +249,7 @@ async def pay_invoice(
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
await create_payment(
new_payment = await create_payment(
checking_id=temp_id,
fee=-fee_reserve_msat,
conn=conn,
@@ -202,7 +267,8 @@ async def pay_invoice(
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to"
" cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
@@ -215,6 +281,7 @@ async def pay_invoice(
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -231,7 +298,8 @@ async def pay_invoice(
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
@@ -248,14 +316,11 @@ async def pay_invoice(
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
if wallet:
await websocketUpdater(
wallet_id,
{
"wallet_balance": wallet.balance or None,
"payment": payment._asdict(),
},
)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
@@ -269,7 +334,8 @@ async def pay_invoice(
)
else:
logger.warning(
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
return invoice.payment_hash
@@ -301,9 +367,10 @@ async def redeem_lnurl_withdraw(
extra=extra,
conn=conn,
)
except:
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
@@ -422,13 +489,26 @@ async def check_transaction_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
# 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
def fee_reserve(amount_msat: int) -> int:
reserve_min = settings.lnbits_reserve_fee_min
reserve_percent = settings.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocketUpdater(
wallet.id,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
@@ -456,7 +536,7 @@ async def check_admin_settings():
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from enviroment variables.")
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
@@ -464,16 +544,8 @@ async def check_admin_settings():
update_cached_settings(settings_db.dict())
# printing settings for debugging
logger.debug("Admin settings:")
for key, value in settings.dict(exclude_none=True).items():
logger.debug(f"{key}: {value}")
admin_url = f"{settings.lnbits_baseurl}wallet?usr={settings.super_user}"
logger.success(f"✔️ Access super user account at: {admin_url}")
# saving it to .super_user file
with open(".super_user", "w") as file:
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
@@ -484,13 +556,41 @@ async def check_admin_settings():
):
send_admin_user_to_saas()
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key not in readonly_variables:
try:
setattr(settings, key, value)
except:
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"])
@@ -511,12 +611,12 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
class WebsocketConnectionManager:
def __init__(self):
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
logger.debug(websocket)
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
@@ -533,3 +633,20 @@ websocketManager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
WALLET = get_wallet_class()
if WALLET.__class__.__name__ == "VoidWallet":
return
set_wallet_class("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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

-43
View File
@@ -1,43 +0,0 @@
new Vue({
el: '#vue',
data: function () {
return {
searchTerm: '',
filteredExtensions: [],
maxStars: 5,
user: null
}
},
mounted() {
this.filteredExtensions = this.g.extensions
},
watch: {
searchTerm(term) {
// Reset the filter
this.filteredExtensions = this.g.extensions
if (term !== '') {
// Filter the extensions list
function extensionNameContains(searchTerm) {
return function (extension) {
return (
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
extension.shortDescription
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
}
}
this.filteredExtensions = this.filteredExtensions.filter(
extensionNameContains(term)
)
}
}
},
created() {
if (window.user) {
this.user = LNbits.map.user(window.user)
}
},
mixins: [windowMixin]
})
+13 -2
View File
@@ -5,14 +5,22 @@ new Vue({
return {
disclaimerDialog: {
show: false,
data: {}
data: {},
description: ''
},
walletName: ''
}
},
computed: {
formatDescription() {
return LNbits.utils.convertMarkdown(this.description)
}
},
methods: {
createWallet: function () {
LNbits.href.createWallet(this.walletName)
LNbits.api.createAccount(this.walletName).then(res => {
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
})
},
processing: function () {
this.$q.notify({
@@ -21,5 +29,8 @@ new Vue({
icon: null
})
}
},
created() {
this.description = SITE_DESCRIPTION
}
})
+178
View File
@@ -0,0 +1,178 @@
function shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
Vue.component('lnbits-node-ranks', {
props: ['ranks'],
data: function () {
return {
user: {},
stats: [
{label: 'Capacity', key: 'capacity'},
{label: 'Channels', key: 'channelcount'},
{label: 'Age', key: 'age'},
{label: 'Growth', key: 'growth'},
{label: 'Availability', key: 'availability'}
]
}
},
template: `
<q-card class='q-my-none'>
<div class='column q-ma-md'>
<h5 class='text-subtitle1 text-bold q-my-none'>1ml Node Rank</h5>
<div v-for='stat in stats' class='q-gutter-sm'>
<div class='row items-center'>
<div class='col-9'>{{ stat.label }}</div>
<div class='col-3 text-subtitle1 text-bold'>
{{ (ranks && ranks[stat.key]) ?? '-' }}
</div>
</div>
</div>
</div>
</q-card>
`
})
Vue.component('lnbits-channel-stats', {
props: ['stats'],
data: function () {
return {
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
template: `
<q-card>
<div class='column q-ma-md'>
<h5 class='text-subtitle1 text-bold q-my-none'>Channels</h5>
<div v-for='state in states' class='q-gutter-sm'>
<div class='row'>
<div class='col-9'>
<q-badge rounded size='md' :color='state.color'>{{ state.label }}</q-badge>
</div>
<div class='col-3 text-subtitle1 text-bold'>
{{ (stats?.counts && stats.counts[state.value]) ?? "-" }}
</div>
</div>
</div>
</div>
</q-card>
`,
created: function () {
if (window.user) {
this.user = LNbits.map.user(window.user)
}
}
})
Vue.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value: function () {
return (
this.amount ??
(this.btc
? LNbits.utils.formatSat(this.btc)
: LNbits.utils.formatMsat(this.msat))
)
}
},
template: `
<q-card>
<q-card-section>
<div class='text-overline text-primary'>
{{ title }}
</div>
<div>
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
<span class='text-h5' v-if='msat != undefined'>sats</span>
<span class='text-h5' v-if='btc != undefined'>BTC</span>
</div>
</q-card-section>
</q-card>
`
})
Vue.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
<div class="text-h6">
<div style="text-align: center">
<qrcode
:value="info.addresses[0]"
:options="{width: 250}"
v-if='info.addresses[0]'
class="rounded-borders"
></qrcode>
<div v-else class='text-subtitle1'>
No addresses available
</div>
</div>
</div>
</q-card-section>
<q-card-actions vertical>
<q-btn
dense
unelevated
size="md"
@click="copyText(info.id)"
>Public Key<q-tooltip> Click to copy </q-tooltip>
</q-btn>
</q-card-actions>
</q-card>
`
})
Vue.component('lnbits-node-info', {
props: ['info'],
data() {
return {
showDialog: false
}
},
mixins: [windowMixin],
methods: {
shortenNodeId
},
template: `
<div class='row items-baseline q-gutter-x-sm'>
<div class='text-h4 text-bold'>{{ this.info.alias }}</div>
<div class='row items-center q-gutter-sm'>
<div class='text-subtitle1 text-light'>{{ this.info.backend_name }}</div>
<q-badge
:style='\`background-color: #\${this.info.color}\`'
class='text-bold'
>
#{{ this.info.color }}
</q-badge>
<div class='text-bold'>{{ shortenNodeId(this.info.id) }}</div>
<q-btn
size='xs'
flat
dense
icon='content_paste'
@click='copyText(info.id)'
></q-btn>
<q-btn
size='xs'
flat
dense
icon='qr_code'
@click='showDialog = true'
></q-btn>
</div>
<q-dialog v-model="showDialog">
<lnbits-node-qrcode :info='info'></lnbits-node-qrcode>
</q-dialog>
</div>
`
})
+30 -1
View File
@@ -1,6 +1,6 @@
// update cache version every time there is a new deployment
// so the service worker reinitializes the cache
const CACHE_VERSION = 30
const CACHE_VERSION = 58
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => {
@@ -56,3 +56,32 @@ self.addEventListener('fetch', event => {
)
}
})
// Handle and show incoming push notifications
self.addEventListener('push', function (event) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
return
}
let data = event.data.json()
const title = data.title
const body = data.body
const url = data.url
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: '/favicon.ico',
data: {
url: url
}
})
)
})
// User can click on the notification message to open wallet
// Installed app will open when `url_handlers` in web app manifest is supported
self.addEventListener('notificationclick', function (event) {
event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data.url))
})
+167 -95
View File
@@ -3,45 +3,24 @@
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
function generateChart(canvas, payments) {
var txs = []
var n = 0
var data = {
labels: [],
income: [],
outcome: [],
cumulative: []
}
_.each(
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
tx => {
txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat
})
function generateChart(canvas, rawData) {
const data = rawData.reduce(
(previous, current) => {
previous.labels.push(current.date)
previous.income.push(current.income)
previous.spending.push(current.spending)
previous.cumulative.push(current.balance)
return previous
},
{
labels: [],
income: [],
spending: [],
cumulative: []
}
)
_.each(_.groupBy(txs, 'hour'), (value, day) => {
var income = _.reduce(
value,
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
0
)
var outcome = _.reduce(
value,
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
0
)
n = n + income - outcome
data.labels.push(day)
data.income.push(income)
data.outcome.push(outcome)
data.cumulative.push(n)
})
new Chart(canvas.getContext('2d'), {
return new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: data.labels,
@@ -64,7 +43,7 @@ function generateChart(canvas, payments) {
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
},
{
data: data.outcome,
data: data.spending,
type: 'bar',
label: 'out',
barPercentage: 0.75,
@@ -85,7 +64,7 @@ function generateChart(canvas, payments) {
{
type: 'time',
display: true,
offset: true,
//offset: true,
time: {
minUnit: 'hour',
stepSize: 3
@@ -137,6 +116,9 @@ new Vue({
comment: ''
},
paymentChecker: null,
copy: {
show: false
},
camera: {
show: false,
camera: 'auto'
@@ -148,14 +130,7 @@ new Vue({
{
name: 'time',
align: 'left',
label: this.$t('date'),
field: 'date',
sortable: true
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
label: this.$t('memo') + '/' + this.$t('date'),
field: 'date',
sortable: true
},
@@ -177,8 +152,89 @@ new Vue({
filter: null,
loading: false
},
paymentsCSV: {
columns: [
{
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
},
{
name: 'time',
align: 'left',
label: this.$t('date'),
field: 'date',
sortable: true
},
{
name: 'amount',
align: 'right',
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: 'sat',
sortable: true
},
{
name: 'fee',
align: 'right',
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
field: 'fee'
},
{
name: 'tag',
align: 'right',
label: this.$t('tag'),
field: 'tag'
},
{
name: 'payment_hash',
align: 'right',
label: this.$t('payment_hash'),
field: 'payment_hash'
},
{
name: 'payment_proof',
align: 'right',
label: this.$t('payment_proof'),
field: 'payment_proof'
},
{
name: 'webhook',
align: 'right',
label: this.$t('webhook'),
field: 'webhook'
},
{
name: 'fiat_currency',
align: 'right',
label: 'Fiat Currency',
field: row => row.extra.wallet_fiat_currency
},
{
name: 'fiat_amount',
align: 'right',
label: 'Fiat Amount',
field: row => row.extra.wallet_fiat_amount
}
],
filter: null,
loading: false
},
paymentsChart: {
show: false
show: false,
group: {value: 'hour', label: 'Hour'},
groupOptions: [
{value: 'month', label: 'Month'},
{value: 'day', label: 'Day'},
{value: 'hour', label: 'Hour'}
],
instance: null
},
disclaimerDialog: {
show: false,
@@ -186,7 +242,10 @@ new Vue({
},
balance: 0,
credit: 0,
newName: ''
update: {
name: null,
currency: null
}
}
},
computed: {
@@ -228,9 +287,27 @@ new Vue({
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
LNbits.api
.request(
'GET',
'/api/v1/payments/history?group=' + this.paymentsChart.group.value,
this.g.wallet.adminkey
)
.then(response => {
this.$nextTick(() => {
if (this.paymentsChart.instance) {
this.paymentsChart.instance.destroy()
}
this.paymentsChart.instance = generateChart(
this.$refs.canvas,
response.data
)
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.paymentsChart.show = false
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
@@ -243,7 +320,6 @@ new Vue({
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
@@ -253,10 +329,13 @@ new Vue({
this.parse.invoice = null
this.parse.lnurlpay = null
this.parse.lnurlauth = null
this.parse.copy.show =
window.isSecureContext && navigator.clipboard?.readText !== undefined
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.paymentChecker = null
this.parse.camera.show = false
this.focusInput('textArea')
},
updateBalance: function (credit) {
LNbits.api
@@ -285,11 +364,6 @@ new Vue({
LNbits.utils.notifyApiError(error)
})
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
@@ -302,7 +376,6 @@ new Vue({
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
@@ -347,19 +420,7 @@ new Vue({
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
this.receive.paymentChecker = setInterval(() => {
let hash = response.data.payment_hash
LNbits.api.getPayment(this.g.wallet, hash).then(response => {
if (response.data.paid) {
this.onPaymentReceived(hash)
}
})
}, 5000)
this.fetchPayments()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@@ -641,7 +702,7 @@ new Vue({
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.then(response => {
.then(_ => {
dismissAuthMsg()
this.$q.notify({
message: `Authentication successful.`,
@@ -664,35 +725,38 @@ new Vue({
}
})
},
updateWalletName: function () {
let newName = this.newName
let adminkey = this.g.wallet.adminkey
if (!newName || !newName.length) return
updateWallet: function (data) {
LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
.then(res => {
this.newName = ''
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(_ => {
this.$q.notify({
message: `Wallet named updated.`,
message: `Wallet updated.`,
type: 'positive',
timeout: 3500
})
LNbits.href.updateWallet(
res.data.name,
this.user.id,
this.g.wallet.id
)
window.location.reload()
})
.catch(err => {
this.newName = ''
LNbits.utils.notifyApiError(err)
})
},
deleteWallet: function (walletId, user) {
deleteWallet: function () {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
LNbits.api
.deleteWallet(this.g.wallet)
.then(_ => {
this.$q.notify({
timeout: 3000,
message: `Wallet deleted!`,
spinner: true
})
})
.catch(err => {
this.paymentsTable.loading = false
LNbits.utils.notifyApiError(err)
})
})
},
fetchPayments: function (props) {
@@ -738,25 +802,33 @@ new Vue({
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = structuredClone(this.paymentsTable.columns)
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
LNbits.api.getPayments(this.g.wallet, {}).then(response => {
const payments = response.data.data.map(LNbits.map.payment)
LNbits.utils.exportCSV(this.paymentsCSV.columns, payments)
})
},
pasteToTextArea: function () {
this.$refs.textArea.focus() // Set cursor to textarea
navigator.clipboard.readText().then(text => {
this.$refs.textArea.value = text
})
LNbits.utils.exportCSV(columns, this.payments)
}
},
watch: {
payments: function () {
this.fetchBalance()
},
'paymentsChart.group': function () {
this.showChart()
}
},
created: function () {
this.fetchBalance()
this.fetchPayments()
this.update.name = this.g.wallet.name
this.update.currency = this.g.wallet.currency
LNbits.api
.request('GET', '/api/v1/currencies')
.then(response => {
+113 -20
View File
@@ -4,34 +4,106 @@ from typing import Dict
import httpx
from loguru import logger
from lnbits.tasks import SseListenersDict, register_invoice_listener
from . import db
from .crud import get_balance_notify, get_wallet
from .models import Payment
from .services import websocketUpdater
from lnbits.core.crud import (
get_balance_notify,
get_wallet,
get_webpush_subscriptions_for_user,
)
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,
)
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
"api_invoice_listeners"
)
async def register_task_listeners():
def register_killswitch():
"""
Registers an invoice listener queue for the core tasks.
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
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)
async def killswitch_task():
while True:
WALLET = get_wallet_class()
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
with httpx.Client() as client:
try:
r = client.get(settings.lnbits_status_manifest, timeout=4)
r.raise_for_status()
if r.status_code == 200:
ks = r.json().get("killswitch")
if ks and ks == 1:
logger.error(
"Switching to VoidWallet. Killswitch triggered."
)
await switch_to_voidwallet()
except (httpx.ConnectError, httpx.RequestError):
logger.error(
"Cannot fetch lnbits status manifest."
f" {settings.lnbits_status_manifest}"
)
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
async def register_watchdog():
"""
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":
try:
delta, *_ = await get_balance_delta()
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}")
await switch_to_voidwallet()
except Exception as e:
logger.error("Error in watchdog task", e)
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
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
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,
dispatches webhooks and balance notifys.
"""
while True:
payment = await invoice_paid_queue.get()
@@ -40,13 +112,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
await dispatch_api_invoice_listeners(payment)
wallet = await get_wallet(payment.wallet_id)
if wallet:
await websocketUpdater(
payment.wallet_id,
{
"wallet_balance": wallet.balance or None,
"payment": payment._asdict(),
},
)
await send_payment_notification(wallet, payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
@@ -61,6 +127,8 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
except (httpx.ConnectError, httpx.RequestError):
pass
await send_payment_push_notification(payment)
async def dispatch_api_invoice_listeners(payment: Payment):
"""
@@ -79,11 +147,15 @@ async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment, -1)
async with httpx.AsyncClient() as client:
data = payment.dict()
try:
logger.debug("sending webhook", payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
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)
@@ -97,3 +169,24 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None:
""",
(status, payment.payment_hash),
)
async def send_payment_push_notification(payment: Payment):
wallet = await get_wallet(payment.wallet_id)
if wallet:
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
+38 -3
View File
@@ -9,12 +9,46 @@
<ul>
{%raw%}
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
<li>Balance: {{balance / 1000}} sats</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%}
</ul>
<br />
</div>
</div>
<div class="row">
<div class="col">
<div v-if="'{{LNBITS_NODE_UI_AVAILABLE}}' === 'True'">
<div>Node Management</div>
<q-toggle
label="Node UI"
v-model="formData.lnbits_node_ui"
></q-toggle>
<q-toggle
v-if="formData.lnbits_node_ui"
label="Public node UI"
v-model="formData.lnbits_public_node_ui"
></q-toggle>
<q-toggle
v-if="formData.lnbits_node_ui"
label="Transactions Tab (Disable on large CLN nodes)"
v-model="formData.lnbits_node_ui_transactions"
></q-toggle>
<br />
</div>
<p v-else>Node Management not supported by active funding source</p>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<div class="row">
@@ -36,7 +70,8 @@
<p>Invoice Expiry</p>
<q-input
filled
v-model="formData.lightning_invoice_expiry"
v-model.number="formData.lightning_invoice_expiry"
type="number"
label="Invoice expiry (seconds)"
mask="#######"
>
@@ -80,7 +115,7 @@
<q-expansion-item
expand-separator
icon="payments"
:label="fund"
:label="prettyFunding.get(fund) || fund"
v-if="funding_sources.get(fund)"
>
<q-card>
@@ -0,0 +1,264 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
<br />
<div>
<div class="row">
<div v-if="serverlogEnabled" class="column" style="width: 100%">
<div
class="col bg-primary"
style="padding-left: 5px; max-height: 20px; color: #fafafa"
v-text="$t('server_logs')"
></div>
<div class="col" style="background-color: #292929">
<q-scroll-area
ref="logScroll"
style="padding: 10px; color: #fafafa; height: 320px"
>
<small v-for="log in logs"
>{% raw %}{{ log }}{% endraw %}<br
/></small>
</q-scroll-area>
</div>
</div>
<q-btn
@click="toggleServerLog"
dense
flat
color="primary"
:label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
></q-btn>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<p v-text="$t('ip_blocker')"></p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
filled
v-model="formBlockedIPs"
@keydown.enter="addBlockedIPs"
type="text"
:label="$t('enter_ip')"
:hint="$t('block_access_hint')"
>
<q-btn
@click="addExtensionsManifest"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="blocked_ip in formData.lnbits_blocked_ips"
:key="blocked_ip"
removable
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
>
{{ blocked_ip }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
<div class="col-6">
<q-input
filled
v-model="formAllowedIPs"
@keydown.enter="addAllowedIPs"
type="text"
:label="$t('enter_ip')"
:hint="$t('allow_access_hint')"
>
<q-btn
@click="addExtensionsManifest"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="allowed_ip in formData.lnbits_allowed_ips"
:key="allowed_ip"
removable
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
>
{{ allowed_ip }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
</div>
</div>
<div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
filled
type="number"
v-model.number="formData.lnbits_rate_limit_no"
:label="$t('number_of_requests')"
></q-input>
</div>
<div class="col-6">
<q-select
filled
:options="[$t('second'),$t('minute'),$t('hour')]"
v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')"
></q-select>
</div>
</div>
</div>
</div>
</div>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,70 @@
{% raw %}
<q-banner v-if="updateAvailable" class="bg-primary text-white">
<q-icon size="28px" name="update"></q-icon>
<span v-text="$t('update_available', {version: statusData.version})"></span>
<template v-slot:action>
<a
class="q-btn"
color="white"
target="_blank"
href="https://github.com/lnbits/lnbits/releases"
v-text="$t('releases')"
></a>
</template>
</q-banner>
<q-banner v-if="!updateAvailable" class="bg-green text-white">
<q-icon size="28px" name="checkmark"></q-icon>
<span v-text="$t('latest_update', {version: statusData.version})"></span>
</q-banner>
<q-card>
<q-card-section>
<q-table
dense
flat
:data="statusData.notifications"
:columns="statusDataTable.columns"
:no-data-label="$t('no_notifications')"
>
<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-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.type === 'update'"
size="18px"
name="update"
color="green"
></q-icon>
<q-icon
v-if="props.row.type === 'warning'"
size="18px"
name="warning"
color="red"
></q-icon>
</q-td>
<q-td auto-width key="date" :props="props">
{{ formatDate(props.row.date) }}
</q-td>
<q-td key="message" :props="props"
>{{ props.row.message }}
<a
v-if="props.row.link"
target="_blank"
:href="props.row.link"
v-text="$t('more')"
></a
></q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
{% endraw %}
@@ -54,6 +54,32 @@
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Allowed currencies</p>
<q-select
filled
v-model="formData.lnbits_allowed_currencies"
multiple
hint="Limit the number of available fiat currencies"
label="Allowed currencies"
:options="{{ currencies | safe }}"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Default Accounting Currency</p>
<q-select
filled
v-model="formData.lnbits_default_accounting_currency"
clearable
hint="Default currency for accounting"
label="Currency"
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
></q-select>
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
+1 -1
View File
@@ -31,7 +31,7 @@
v-model="formData.lnbits_site_description"
filled
type="textarea"
hint="Use plain text or raw HTML"
hint="Use plain text, Markdown, or raw HTML"
/>
</div>
<br />
+172 -7
View File
@@ -88,6 +88,12 @@
@update="val => tab = val.name"
></q-tab>
<q-tab
name="security"
:label="$t('security')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('theme')"
@@ -101,7 +107,8 @@
<q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_theme.html" %}
include "admin/_tab_security.html" %} {% include
"admin/_tab_theme.html" %}
</q-tab-panels>
</q-form>
</q-card>
@@ -164,6 +171,8 @@
data: function () {
return {
settings: {},
logs: [],
serverlogEnabled: false,
lnbits_theme_options: [
'classic',
'bitcoin',
@@ -175,10 +184,30 @@
'monochrome',
'salvador'
],
auditData: {},
statusData: {},
statusDataTable: {
columns: [
{
name: 'date',
align: 'left',
label: this.$t('date'),
field: 'date'
},
{
name: 'message',
align: 'left',
label: this.$t('memo'),
field: 'message'
}
]
},
formData: {},
formAddAdmin: '',
formAddUser: '',
formAddExtensionsManifest: '',
formAllowedIPs: '',
formBlockedIPs: '',
isSuperUser: false,
wallet: {},
cancel: {},
@@ -187,6 +216,21 @@
},
tab: 'funding',
needsRestart: false,
prettyFunding: new Map([
['VoidWallet', 'Void Wallet'],
['FakeWallet', 'Fake Wallet'],
['CoreLightningWallet', 'Core Lightning'],
['CoreLightningRestWallet', 'Core Lightning'],
['LndRestWallet', 'Lightning Network Daemon (LND Rest)'],
['LndWallet', 'Lightning Network Daemon (LND)'],
['LnTipsWallet', 'LN.Tips'],
['LNPayWallet', 'LN Pay'],
['EclairWallet', 'Eclair (ACINQ)'],
['LNbitsWallet', 'LNBits'],
['OpenNodeWallet', 'OpenNode'],
['ClicheWallet', 'Cliche (NBD)'],
['SparkWallet', 'Spark']
]),
funding_sources: new Map([
['VoidWallet', null],
[
@@ -207,6 +251,23 @@
}
}
],
[
'CoreLightningRestWallet',
{
corelightning_rest_url: {
value: null,
label: 'Endpoint'
},
corelightning_rest_cert: {
value: null,
label: 'Certificate'
},
corelightning_rest_macaroon: {
value: null,
label: 'Macaroon'
}
}
],
[
'LndRestWallet',
{
@@ -347,13 +408,20 @@
])
}
},
created: function () {
created() {
this.getSettings()
this.getAudit()
this.balance = +'{{ balance|safe }}'
},
computed: {
lnbitsVersion() {
return LNBITS_VERSION
},
checkChanges() {
return !_.isEqual(this.settings, this.formData)
},
updateAvailable() {
return LNBITS_VERSION !== this.statusData.version
}
},
methods: {
@@ -364,7 +432,6 @@
//admin_users = [...admin_users, addUser]
this.formData.lnbits_admin_users = [...admin_users, addUser]
this.formAddAdmin = ''
//console.log(this.checkChanges)
}
},
removeAdminUser(user) {
@@ -406,6 +473,68 @@
m => m !== manifest
)
},
async toggleServerLog() {
this.serverlogEnabled = !this.serverlogEnabled
if (this.serverlogEnabled) {
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
const localUrl =
wsProto +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
digestHex
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {
this.logs.push(data.toString())
const scrollArea = this.$refs.logScroll
if (scrollArea) {
const scrollTarget = scrollArea.getScrollTarget()
const duration = 0
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
}
})
} else {
this.ws.close()
}
},
addAllowedIPs() {
const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips
if (
allowedIPs &&
allowedIPs.length &&
!allowed_ips.includes(allowedIPs)
) {
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
this.formAllowedIPs = ''
}
},
removeAllowedIPs(allowed_ip) {
const allowed_ips = this.formData.lnbits_allowed_ips
this.formData.lnbits_allowed_ips = allowed_ips.filter(
a => a !== allowed_ip
)
},
addBlockedIPs() {
const blockedIPs = this.formBlockedIPs.trim()
const blocked_ips = this.formData.lnbits_blocked_ips
if (
blockedIPs &&
blockedIPs.length &&
!blocked_ips.includes(blockedIPs)
) {
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
this.formBlockedIPs = ''
}
},
removeBlockedIPs(blocked_ip) {
const blocked_ips = this.formData.lnbits_blocked_ips
this.formData.lnbits_blocked_ips = blocked_ips.filter(
b => b !== blocked_ip
)
},
restartServer() {
LNbits.api
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
@@ -449,12 +578,43 @@
this.settings.lnbits_allowed_funding_sources.map(f => {
let opts = this.funding_sources.get(f)
if (!opts) return
Object.keys(opts).forEach(e => {
opts[e].value = this.settings[e]
})
})
},
formatDate(date) {
return moment(date * 1000).fromNow()
},
getNotifications() {
if (this.settings.lnbits_notifications) {
axios
.get(this.settings.lnbits_status_manifest)
.then(response => {
this.statusData = response.data
})
.catch(error => {
this.formData.lnbits_notifications = false
error.response.data = {}
error.response.data.message = 'Could not fetch status manifest.'
LNbits.utils.notifyApiError(error)
})
}
},
getAudit() {
LNbits.api
.request(
'GET',
'/admin/api/v1/audit/?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(response => {
this.auditData = response.data
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getSettings() {
LNbits.api
.request(
@@ -465,8 +625,9 @@
.then(response => {
this.isSuperUser = response.data.is_super_user || false
this.settings = response.data
this.formData = _.clone(this.settings)
this.formData = {...this.settings}
this.updateFundingData()
this.getNotifications()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@@ -487,13 +648,17 @@
.then(response => {
this.needsRestart =
this.settings.lnbits_backend_wallet_class !==
this.formData.lnbits_backend_wallet_class
this.formData.lnbits_backend_wallet_class ||
this.settings.lnbits_killswitch !==
this.formData.lnbits_killswitch
this.settings = this.formData
this.formData = _.clone(this.settings)
this.updateFundingData()
this.$q.notify({
type: 'positive',
message: 'Success! Settings changed!',
message: `Success! Settings changed! ${
this.needsRestart ? 'Restart required!' : ''
}`,
icon: null
})
})
+101 -5
View File
@@ -108,7 +108,8 @@
class="text-subtitle2 gt-sm"
style="font-size: 11px; height: 34px"
>
{{ extension.shortDescription }}
{{ extension.shortDescription ||
extension.installedRelease?.description }}
</div>
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
{{ extension.name }}
@@ -164,7 +165,7 @@
<q-toggle
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') "
color="secodary"
color="secondary"
style="max-height: 21px"
v-model="extension.isActive"
@input="toggleExtension(extension)"
@@ -184,7 +185,7 @@
flat
color="primary"
type="a"
:href="[extension.id, '?usr=', g.user.id].join('')"
:href="[extension.id, '/?usr=', g.user.id].join('')"
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
>
<q-btn
@@ -198,8 +199,11 @@
>
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
>
<q-badge v-if="extension.isAdminOnly && !user.admin">
{%raw%}{{ $t('admin_only') }}{%endraw%}
</q-badge>
<q-btn
v-if="extension.isInstalled && !user.extensions.includes(extension.id) && extension.isActive"
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
flat
color="primary"
type="a"
@@ -255,6 +259,17 @@
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
</p>
<div class="row q-mt-lg">
<q-checkbox
v-model="uninstallAndDropDb"
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>
</q-checkbox>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="uninstallExtension()"
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
@@ -266,6 +281,32 @@
</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>
<q-input
v-model="dropDbExtensionId"
:label="selectedExtension.id"
></q-input>
<br />
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
<div class="row q-mt-lg">
<q-btn
:disable="dropDbExtensionId !== selectedExtension.id"
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
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showUpgradeDialog">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section>
@@ -300,6 +341,11 @@
{%raw%}{{ $t('repository') }}{%endraw%}
<br />
<small v-text="repoName"></small>
<q-tooltip
><span
v-text="selectedExtensionRepos[repoName].repo"
></span
></q-tooltip>
</div>
<div class="col-2"></div>
</div>
@@ -389,6 +435,13 @@
>
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
>
<q-btn
v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()"
flat
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
>
@@ -407,8 +460,11 @@
filteredExtensions: null,
showUninstallDialog: false,
showUpgradeDialog: false,
showDropDbDialog: false,
dropDbExtensionId: '',
selectedExtension: null,
selectedExtensionRepos: null,
uninstallAndDropDb: false,
maxStars: 5,
user: null
}
@@ -497,6 +553,40 @@
this.filteredExtensions = this.extensions.concat([])
this.handleTabChanged('installed')
this.tab = 'installed'
this.$q.notify({
type: 'positive',
message: 'Extension uninstalled!'
})
if (this.uninstallAndDropDb) {
this.showDropDb()
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.inProgress = false
})
},
dropExtensionDb: async function () {
const extension = this.selectedExtension
this.showUpgradeDialog = false
this.showDropDbDialog = false
this.dropDbExtensionId = ''
extension.inProgress = true
LNbits.api
.request(
'DELETE',
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`,
this.g.user.wallets[0].adminkey
)
.then(response => {
extension.installedRelease = null
extension.inProgress = false
extension.hasDatabaseTables = false
this.$q.notify({
type: 'positive',
message: 'Extension DB deleted!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@@ -525,6 +615,11 @@
showUninstall: function () {
this.showUpgradeDialog = false
this.showUninstallDialog = true
this.uninstallAndDropDb = false
},
showDropDb: function () {
this.showDropDbDialog = true
},
showUpgrade: async function (extension) {
@@ -541,7 +636,8 @@
this.selectedExtensionRepos = data.reduce((repos, release) => {
repos[release.source_repo] = repos[release.source_repo] || {
releases: [],
isInstalled: false
isInstalled: false,
repo: release.repo
}
release.inProgress = false
release.error = null
+18 -9
View File
@@ -61,7 +61,9 @@
></q-btn>
</div>
</div>
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
<div v-else>
<div v-html="formatDescription"></div>
</div>
</q-card-section>
</q-card>
</div>
@@ -124,6 +126,16 @@
</a>
</div>
<div class="col q-pl-md">
<a href="https://start9.com/">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/start9.png' : '/static/images/start9l.png'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a href="https://getumbrel.com/">
<q-img
contain
@@ -131,9 +143,7 @@
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<div class="col q-pl-md">
<a href="https://mynodebtc.com">
<q-img
contain
@@ -141,7 +151,9 @@
></q-img>
</a>
</div>
<div class="col q-pl-md">
</div>
<div class="row">
<div class="col">
<a href="https://github.com/shesek/spark-wallet">
<q-img
contain
@@ -149,9 +161,7 @@
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<div class="col q-pl-md">
<a href="https://voltage.cloud">
<q-img
contain
@@ -159,7 +169,6 @@
></q-img>
</a>
</div>
<div class="col q-pl-md"></div>
</div>
</div>
+88 -16
View File
@@ -149,6 +149,7 @@
{% raw %}
<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
>
@@ -174,7 +175,7 @@
</q-icon>
</q-td>
<q-td
key="memo"
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
@@ -192,8 +193,12 @@
</q-badge>
{{ props.row.memo }}
<br />
<q-tooltip>{{ props.row.date }}</q-tooltip>
<i> {{ props.row.dateFrom }}</i>
<i>
{{ props.row.dateFrom }}<q-tooltip
>{{ props.row.date }}</q-tooltip
></i
>
</q-td>
{% endraw %}
@@ -206,10 +211,17 @@
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
}}
</q-td>
<q-td auto-width key="amount" v-else :props="props">
{{ props.row.fsat }}<br />
<i>fee {{ props.row.fee }}</i>
<i v-if="props.row.extra.wallet_fiat_currency">
{{ props.row.extra.wallet_fiat_currency }} {{
props.row.extra.wallet_fiat_amount }}
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
{{ props.row.extra.fiat_currency }} {{
props.row.extra.fiat_amount }}
</i>
</q-td>
</q-tr>
@@ -323,7 +335,7 @@
:options="{width:240}"
></qrcode>
</a>
<p v-text="$('drain_funds_desc')"></p>
<p v-text="$t('drain_funds_desc')"></p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -356,23 +368,54 @@
<div class="" style="max-width: 320px">
<q-input
filled
v-model.trim="newName"
label="Label"
dense="dense"
v-model.trim="update.name"
label="Name"
dense
@update:model-value="(e) => console.log(e)"
/>
</div>
<q-btn
:disable="!newName.length"
:disable="!update.name.length"
unelevated
class="q-mt-sm"
color="primary"
:label="$t('update_name')"
@click="updateWalletName()"
@click="updateWallet({ name: update.name })"
></q-btn>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="attach_money"
:label="$t('fiat_tracking')"
>
<q-card>
<q-card-section>
<div style="max-width: 360px">
<q-select
filled
dense
clearable
v-model="update.currency"
type="text"
:label="$t('currency')"
:options="receive.units.filter((u) => u !== 'sat')"
></q-select>
</div>
<q-btn
:disable="!update.name.length"
unelevated
class="q-mt-sm"
color="primary"
:label="$t('update_currency')"
@click="updateWallet({ currency: update.currency || '' })"
></q-btn>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
@@ -385,7 +428,7 @@
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
@click="deleteWallet()"
:label="$t('delete_wallet')"
></q-btn>
</q-card-section>
@@ -423,7 +466,7 @@
</div>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
<q-dialog v-model="receive.show">
{% raw %}
<q-card
v-if="!receive.paymentReq"
@@ -438,7 +481,7 @@
filled
dense
v-model.number="receive.data.amount"
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
mask="#.##"
fill-mask="0"
reverse-fill-mask
@@ -706,6 +749,7 @@
v-model.trim="parse.data.request"
type="textarea"
:label="$t('paste_invoice_label')"
ref="textArea"
>
</q-input>
<div class="row q-mt-lg">
@@ -716,6 +760,17 @@
type="submit"
:label="$t('read')"
></q-btn>
<q-icon
name="content_paste"
color="grey"
class="q-mt-xs q-ml-sm q-mr-auto"
v-if="parse.copy.show"
@click="pasteToTextArea"
><q-tooltip
>{% raw %}{{$t('paste_from_clipboard')}}{% endraw
%}</q-tooltip
></q-icon
>
<q-btn
v-close-popup
flat
@@ -772,6 +827,19 @@
<q-dialog v-model="paymentsChart.show">
<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>
@@ -783,10 +851,14 @@
indicator-color="transparent"
align="justify"
>
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
>
</q-tab>
<q-tab @click="showParseDialog" icon="file_upload" label="Send">
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
</q-tab>
<q-btn
round
@@ -0,0 +1,312 @@
<q-tab-panel name="channels">
<q-dialog v-model="connectPeerDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="connectPeerDialog.data.uri"
label="Node URI"
hint="pubkey@host:port"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('connect')"
color="primary"
@click="connectPeer"
></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>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="openChannelDialog.data.peer_id"
label="Peer ID"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.funding_amount"
label="Funding Amount"
></q-input>
<q-expansion-item icon="warning" label="Advanced">
<q-card>
<q-card-section>
<div class="column q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.push_amount"
label="Push Amount"
hint="This gifts sats to the other side!"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.fee_rate"
label="Fee Rate"
></q-input>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
:label="$t('open')"
color="primary"
@click="openChannel"
></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>
<q-dialog v-model="closeChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<div>
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('close')"
color="primary"
@click="closeChannel"
></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>
<q-card-section class="q-pa-none">
<div class="row q-col-gutter-lg">
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="q-gutter-y-sm">
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
<q-input
filled
dense
clearable
v-model="channels.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-select
dense
size="sm"
style="min-width: 200px"
filled
multiple
clearable
v-model="stateFilters"
:options="this.states"
class="col-auto"
></q-select>
<q-btn
unelevated
color="primary"
size="md"
class="col-auto"
@click="showOpenChannelDialog()"
>
Open channel
</q-btn>
</div>
{% raw %}
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
:balance="this.totalBalance"
></lnbits-channel-balance>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:data="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<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-caption" v-if="props.row.short_id">
{{ props.row.short_id }}
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.short_id)"
></q-btn>
</div>
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
>
{{ states.find(s => s.value == props.row.state)?.label
}}
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
flat
dense
size="md"
@click="showCloseChannelDialog(props.row)"
icon="cancel"
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
></lnbits-channel-balance>
</div>
</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"
>
<div class="col-grow text-h6 q-my-none">Peers</div>
<q-input
filled
dense
clearable
v-model="peers.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-btn
class="col-auto"
color="primary"
@click="connectPeerDialog.show = true"
>
Connect Peer
</q-btn>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:data="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<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="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
>
#{{ props.row.color }}
</q-badge>
<div class="text-bold">
{{ shortenNodeId(props.row.id) }}
</div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row)"
></q-btn>
</div>
</div>
<q-btn
unelevated
color="primary"
@click="showOpenChannelDialog(props.row.id)"
>
Open channel
</q-btn>
<q-btn
flat
dense
size="md"
@click="disconnectPeer(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</div>
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,70 @@
<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">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Total Capacity"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Fees collected"
:msat="this.info.fees?.total_msat"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Balance"
:btc="this.info.onchain_balance_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Confirmed"
:btc="this.info.onchain_confirmed_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats
:stats="this.channel_stats"
></lnbits-channel-stats>
</div>
</div>
{% endraw %}
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,316 @@
<q-tab-panel name="transactions">
<q-card-section class="q-pa-none">
<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"
>
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
</div>
<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 }}
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div
class="row"
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
{{ transactionDetailsDialog.data.preimage }}
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
</div>
</div>
<div
v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(transactionDetailsDialog.data.bolt11)"
:label="$t('copy_invoice')"
class="q-mt-sm"
></q-btn>
</div>
</div>
{% endraw %}
</q-card-section>
</q-card>
</q-dialog>
<div class="row q-col-gutter-md q-pb-lg"></div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Payments</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:data="paymentsTable.data"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@request="getPayments"
>
{% raw %}
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_made"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-dialog v-model="props.row.expand" :props="props">
<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">
<qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-td>
</template>
<template v-slot:body-cell-date="props">
<q-td auto-width key="date" :props="props">
<lnbits-date :ts="props.row.time"></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-destination="props">
<q-td auto-width key="destination">
<div class="row items-center justify-between no-wrap">
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
>
{{ props.row.destination?.alias }}
</q-badge>
<div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(info.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row.destination)"
></q-btn>
</div>
</div>
</q-td>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Invoices</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:data="invoiceTable.data"
:columns="invoiceTable.columns"
:pagination.sync="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
>
{% raw %}
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_received"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-paid_at="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.paid_at"
:ts="props.row.paid_at"
></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-expiry="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.expiry"
:ts="props.row.expiry"
></lnbits-date>
</q-td>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
+500
View File
@@ -0,0 +1,500 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<q-dialog v-model="nodeInfoDialog.show">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
</q-dialog>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="dashboard"
:label="$t('dashboard')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="channels"
:label="$t('channels')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="transactions"
:label="$t('transactions')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "node/_tab_dashboard.html" %} {% include
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
%}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/core/static/js/node.js"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
{% raw %}
Vue.component('lnbits-stat',
{
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value: function () {
return this.amount ?? (this.btc ? LNbits.utils.formatSat(this.btc) : LNbits.utils.formatMsat(this.msat))
}
},
template: `
<q-card>
<q-card-section>
<div class='text-overline text-primary'>
{{ title }}
</div>
<div>
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
<span class='text-h5' v-if='msat != undefined'>sats</span>
<span class='text-h5' v-if='btc != undefined'>BTC</span>
</div>
</q-card-section>
</q-card>
`
}
)
Vue.component('lnbits-channel-balance',
{
props: ['balance', 'color'],
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
}
},
template: `
<div>
<div class="row items-center justify-between">
<span class="text-weight-thin">
Local: {{ formatMsat(balance.local_msat) }}
sats
</span>
<span class="text-weight-thin">
Remote: {{ formatMsat(balance.remote_msat) }}
sats
</span>
</div>
<q-linear-progress
rounded
size="25px"
:value="balance.local_msat / balance.total_msat"
:color="color"
:style="\`color: #\${this.color}\`"
>
<div class="absolute-full flex flex-center">
<q-badge
color="white"
text-color="accent"
:label="formatMsat(balance.total_msat) + ' sats'"
>
{{ balance.alias }}
</q-badge>
</div>
</q-linear-progress>
</div>
`
}
)
Vue.component('lnbits-date',
{
props: ['ts'],
computed: {
date: function () {
return Quasar.utils.date.formatDate(
new Date(this.ts * 1000),
'YYYY-MM-DD HH:mm'
)
},
dateFrom: function () {
return moment(this.date).fromNow()
}
},
template: `
<div>
<q-tooltip>{{ this.date }}</q-tooltip>
{{ this.dateFrom }}
</div>
`
}
)
Vue.component('lnbits-date',
{
props: ['ts'],
computed: {
date: function () {
return Quasar.utils.date.formatDate(
new Date(this.ts * 1000),
'YYYY-MM-DD HH:mm'
)
},
dateFrom: function () {
return moment(this.date).fromNow()
}
},
template: `
<div>
<q-tooltip>{{ this.date }}</q-tooltip>
{{ this.dateFrom }}
</div>
`
}
)
{% endraw %}
new Vue({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: {
data: [],
filter: ''
},
activeBalance: {},
ranks: {},
peers: {
data: [],
filter: '',
},
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
transactionDetailsDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
],
stateFilters: [
{label: 'Active', value: 'active'},
{label: 'Pending', value: 'pending'}
],
paymentsTable: {
data: [],
columns: [
{
name: 'pending',
label: ''
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
},
{
name: 'date',
align: 'left',
label: this.$t('date'),
field: 'date',
sortable: true
},
{
name: 'sat',
align: 'right',
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: row => this.formatMsat(row.amount),
sortable: true
},
{
name: 'fee',
align: 'right',
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
field: 'fee'
},
{
name: 'destination',
align: 'right',
label: 'Destination',
field: 'destination'
}
],
pagination: {
rowsPerPage: 10,
page: 1,
rowsNumber: 10
},
filter: null
},
invoiceTable: {
data: [],
columns: [
{
name: 'pending',
label: ''
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
},
{
name: 'paid_at',
field: 'paid_at',
align: 'right',
label: 'Paid at',
sortable: true
},
{
name: 'expiry',
label: this.$t('expiry'),
field: 'expiry',
align: 'right',
sortable: true
},
{
name: 'amount',
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: row => this.formatMsat(row.amount),
sortable: true
},
],
pagination: {
rowsPerPage: 10,
page: 1,
rowsNumber: 10
},
filter: null
}
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
watch: {
tab: function (val) {
if (val === 'transactions' && !this.paymentsTable.data.length) {
this.getPayments()
this.getInvoices()
} else if (val === 'channels' && !this.channels.data.length) {
this.getChannels()
this.getPeers()
}
}
},
computed: {
checkChanges() {
return !_.isEqual(this.settings, this.formData)
},
filteredChannels: function () {
return this.stateFilters ? this.channels.data.filter(channel => {
return this.stateFilters.find(({value}) => value == channel.state)
}) : this.channels.data
},
totalBalance: function () {
return this.filteredChannels.reduce(
(balance, channel) => {
balance.local_msat += channel.balance.local_msat
balance.remote_msat += channel.balance.remote_msat
balance.total_msat += channel.balance.total_msat
return balance
},
{local_msat: 0, remote_msat: 0, total_msat: 0}
)
},
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, options) {
const params = new URLSearchParams(options?.query)
params.set('usr', this.g.user.id)
return LNbits.api.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
getChannels: function () {
return this.api('GET', '/channels')
.then(response => {
this.channels.data = response.data
})
},
getInfo: function () {
return this.api('GET', '/info')
.then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
return this.api('GET', '/rank')
.then(response => {
this.ranks = response.data
})
},
getPayments: function (props) {
if (props) {
this.paymentsTable.pagination = props.pagination
}
let pagination = this.paymentsTable.pagination
const query = {
limit: pagination.rowsPerPage,
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
}
return this.api('GET', '/payments', {query})
.then(response => {
this.paymentsTable.data = response.data.data
this.paymentsTable.pagination.rowsNumber = response.data.total
})
},
getInvoices: function (props) {
if (props) {
this.invoiceTable.pagination = props.pagination
}
let pagination = this.invoiceTable.pagination
const query = {
limit: pagination.rowsPerPage,
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
}
return this.api('GET', '/invoices', {query})
.then(response => {
this.invoiceTable.data = response.data.data
this.invoiceTable.pagination.rowsNumber = response.data.total
})
},
getPeers: function () {
return this.api('GET', '/peers')
.then(response => {
this.peers.data = response.data
console.log('peers', this.peers)
})
},
connectPeer: function () {
console.log('peer', this.connectPeerDialog)
this.api('POST', '/peers', {data: this.connectPeerDialog.data})
.then(() => {
this.connectPeerDialog.show = false
this.getPeers()
})
},
disconnectPeer: function(id) {
LNbits.utils
.confirmDialog(
'Do you really wanna disconnect this peer?'
)
.onOk(() => {
this.api('DELETE', `/peers/${id}`)
.then(response => {
this.$q.notify({
message: 'Disconnected',
icon: null
})
this.needsRestart = true
this.getPeers()
})
})
},
openChannel: function () {
this.api('POST', '/channels', {data: this.openChannelDialog.data})
.then(response => {
this.openChannelDialog.show = false
this.getChannels()
})
.catch(error => {
console.log(error)
})
},
showCloseChannelDialog: function (channel) {
this.closeChannelDialog.show = true
this.closeChannelDialog.data = {force: false, short_id: channel.short_id, ...channel.point}
},
closeChannel: function () {
this.api('DELETE', '/channels', {query: this.closeChannelDialog.data})
.then(response => {
this.closeChannelDialog.show = false
this.getChannels()
})
},
showOpenChannelDialog: function (peer_id) {
this.openChannelDialog.show = true
this.openChannelDialog.data = {peer_id, funding_amount: 0}
},
showNodeInfoDialog: function (node) {
this.nodeInfoDialog.show = true
this.nodeInfoDialog.data = node
},
showTransactionDetailsDialog: function (details) {
this.transactionDetailsDialog.show = true
this.transactionDetailsDialog.data = details
console.log('details', details)
},
exportCSV: function () {
},
shortenNodeId
}
})
</script>
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
<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">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
title="Total Capacity"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/core/static/js/node.js"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
{% raw %}
{% endraw %}
new Vue({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: [],
activeBalance: {},
ranks: {},
peers: [],
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
],
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, data) {
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
},
getInfo: function () {
this.api('GET', '/info', {})
.then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
this.api('GET', '/rank', {})
.then(response => {
this.ranks = response.data
})
},
}
})
</script>
{% endblock %}
+53 -23
View File
@@ -6,22 +6,49 @@ from subprocess import Popen
from typing import Optional
from urllib.parse import urlparse
from fastapi import Body, Depends
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 User
from lnbits.core.services import update_cached_settings, update_wallet_balance
from lnbits.core.models import CreateTopup, User
from lnbits.core.services import (
get_balance_delta,
update_cached_settings,
update_wallet_balance,
)
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, EditableSettings, settings
from lnbits.settings import AdminSettings, UpdateSettings, settings
from .. import core_app
from .. import core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
admin_router = APIRouter()
@core_app.get("/admin/api/v1/settings/")
@admin_router.get(
"/admin/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.",
)
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
) -> Optional[AdminSettings]:
@@ -29,21 +56,20 @@ async def api_get_settings(
return admin_settings
@core_app.put(
@admin_router.put(
"/admin/api/v1/settings/",
status_code=HTTPStatus.OK,
)
async def api_update_settings(
data: EditableSettings, user: User = Depends(check_admin)
):
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found."
update_cached_settings(admin_settings.dict())
core_app_extra.register_new_ratelimiter()
return {"status": "Success"}
@core_app.delete(
@admin_router.delete(
"/admin/api/v1/settings/",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
@@ -53,7 +79,7 @@ async def api_delete_settings() -> None:
server_restart.set()
@core_app.get(
@admin_router.get(
"/admin/api/v1/restart/",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
@@ -63,27 +89,31 @@ async def api_restart_server() -> dict[str, str]:
return {"status": "Success"}
@core_app.put(
@admin_router.put(
"/admin/api/v1/topup/",
name="Topup",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(
id: str = Body(...), amount: int = Body(...)
) -> dict[str, str]:
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
try:
await get_wallet(id)
except:
await get_wallet(data.id)
except Exception:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
)
await update_wallet_balance(wallet_id=id, amount=int(amount))
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"}
@core_app.get(
@admin_router.get(
"/admin/api/v1/backup/",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
@@ -100,10 +130,10 @@ async def api_download_backup() -> FileResponse:
p = urlparse(db_url)
command = (
f"pg_dump --host={p.hostname} "
f'--dbname={p.path.replace("/", "")} '
f"--dbname={p.path.replace('/', '')} "
f"--username={p.username} "
f"--no-password "
f"--format=c "
"--no-password "
"--format=c "
f"--file={pg_backup_filename}"
)
proc = Popen(
+293 -223
View File
@@ -1,39 +1,53 @@
import asyncio
import base64
import hashlib
import json
import time
import uuid
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, urlunparse
import async_timeout
import httpx
import pyqrcode
from fastapi import (
APIRouter,
Body,
Depends,
Header,
Query,
Request,
Response,
WebSocket,
WebSocketDisconnect,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from pydantic import BaseModel
from pydantic.fields import Field
from sse_starlette.sse import EventSourceResponse
from starlette.responses import RedirectResponse, StreamingResponse
from lnbits import bolt11, lnurl
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 Payment, PaymentFilters, User, Wallet
from lnbits.core.models import (
ConversionData,
CreateInvoice,
CreateLnurl,
CreateLnurlAuth,
CreateWallet,
CreateWebPushSubscription,
DecodePayment,
Payment,
PaymentFilters,
PaymentHistoryPoint,
Query,
User,
Wallet,
WalletType,
WebPushSubscription,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
@@ -52,28 +66,37 @@ from lnbits.extension_manager import (
get_valid_extensions,
)
from lnbits.helpers import generate_filter_params_openapi, url_for
from lnbits.settings import get_wallet_class, settings
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
currencies,
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from .. import core_app, core_app_extra, db
from ..crud import (
DateTrunc,
add_installed_extension,
create_account,
create_tinyurl,
create_wallet,
create_webpush_subscription,
delete_dbversion,
delete_installed_extension,
delete_tinyurl,
delete_wallet,
delete_webpush_subscription,
drop_extension_db,
get_dbversions,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_tinyurl,
get_tinyurl_by_url,
get_total_balance,
get_wallet_for_key,
get_webpush_subscription,
save_balance_check,
update_pending_payments,
update_wallet,
)
from ..services import (
@@ -88,15 +111,17 @@ from ..services import (
)
from ..tasks import api_invoice_listeners
api_router = APIRouter()
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
@core_app.get("/api/v1/wallet")
@api_router.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == 0:
if wallet.wallet_type == WalletType.admin:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
@@ -106,8 +131,8 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@core_app.put("/api/v1/wallet/{new_name}")
async def api_update_wallet(
@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)
@@ -118,7 +143,45 @@ async def api_update_wallet(
}
@core_app.get(
@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.post("/api/v1/account", response_model=Wallet)
async def api_create_account(data: CreateWallet) -> Wallet:
if len(settings.lnbits_allowed_users) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
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",
@@ -130,16 +193,7 @@ async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
pending_payments = await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
exclude_uncheckable=True,
filters=filters,
)
for payment in pending_payments:
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
await update_pending_payments(wallet.wallet.id)
return await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
@@ -148,7 +202,22 @@ async def api_payments(
)
@core_app.get(
@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",
@@ -160,16 +229,7 @@ async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
pending = await get_payments_paginated(
wallet_id=wallet.wallet.id,
pending=True,
exclude_uncheckable=True,
filters=filters,
)
for payment in pending.data:
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
await update_pending_payments(wallet.wallet.id)
page = await get_payments_paginated(
wallet_id=wallet.wallet.id,
pending=True,
@@ -179,57 +239,37 @@ async def api_payments_paginated(
return page
class CreateInvoiceData(BaseModel):
out: Optional[bool] = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
unit: Optional[str] = "sat"
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
internal: Optional[bool] = False
bolt11: Optional[str] = None
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
try:
description_hash = (
bytes.fromhex(data.description_hash) if data.description_hash else b""
)
unhashed_description = (
bytes.fromhex(data.unhashed_description)
if data.unhashed_description
else b""
)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
)
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 = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.unit == "sat":
amount = int(data.amount)
else:
assert data.unit is not None, "unit not set"
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
amount = price_in_sats
async with db.connect() as conn:
try:
_, payment_request = await create_invoice(
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=amount,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
@@ -238,6 +278,12 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
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:
@@ -280,7 +326,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"checking_id": checking_id,
"lnurl_response": lnurl_response,
}
@@ -304,15 +350,24 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
}
@core_app.post(
@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: CreateInvoiceData = Body(...),
invoiceData: CreateInvoice = Body(...),
):
if invoiceData.out is True and wallet.wallet_type == 0:
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
if not invoiceData.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -331,17 +386,9 @@ async def api_payments_create(
)
class CreateLNURLData(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
@core_app.post("/api/v1/payments/lnurl")
@api_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
domain = urlparse(data.callback).netloc
@@ -377,13 +424,12 @@ async def api_payments_pay_lnurl(
if invoice.amount_msat != data.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
)
if invoice.description_hash != data.description_hash:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {data.amount} msat, got {invoice.amount_msat}."
),
),
)
extra = {}
@@ -421,38 +467,24 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
logger.debug(f"adding sse listener for wallet: {uid}")
api_invoice_listeners[uid] = payment_queue
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
async def payment_received() -> None:
while True:
try:
async with async_timeout.timeout(1):
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
logger.debug("sse listener: payment received", payment)
await send_queue.put(("payment-received", payment))
except asyncio.TimeoutError:
pass
task = asyncio.create_task(payment_received())
try:
while True:
if await request.is_disconnected():
await request.close()
break
typ, data = await send_queue.get()
if data:
jdata = json.dumps(dict(data.dict(), pending=False))
yield dict(data=jdata, event=typ)
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)
task.cancel()
return
@core_app.get("/api/v1/payments/sse")
@api_router.get("/api/v1/payments/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
@@ -464,14 +496,15 @@ async def api_payments_sse(
# TODO: refactor this route into a public and admin one
@core_app.get("/api/v1/payments/{payment_hash}")
@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 type(X_Api_Key) == str else None # type: ignore
# 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
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
)
@@ -508,12 +541,12 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
return {"paid": not payment.pending, "preimage": payment.preimage}
@core_app.get("/api/v1/lnurlscan/{code}")
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
url = lnurl.decode(code)
domain = urlparse(url).netloc
except:
except Exception:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
@@ -541,8 +574,9 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
assert lnurlauth_key.verifying_key
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(follow_redirects=True) as client:
r = await client.get(url, timeout=5)
r.raise_for_status()
if r.is_error:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
@@ -622,45 +656,28 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
return params
class DecodePayment(BaseModel):
data: str
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment, response: Response):
@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 = lnurl.decode(payment_str)
return {"domain": url}
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
return {
"payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat,
"description": invoice.description,
"description_hash": invoice.description_hash,
"payee": invoice.payee,
"date": invoice.date,
"expiry": invoice.expiry,
"secret": invoice.secret,
"route_hints": invoice.route_hints,
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
}
except:
response.status_code = HTTPStatus.BAD_REQUEST
return {"message": "Failed to decode"}
return JSONResponse(invoice.data)
except Exception as exc:
return JSONResponse(
{"message": f"Failed to decode: {str(exc)}"},
status_code=HTTPStatus.BAD_REQUEST,
)
class Callback(BaseModel):
callback: str = Query(...)
@core_app.post("/api/v1/lnurlauth")
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(callback.callback, wallet=wallet)
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
@@ -668,18 +685,18 @@ async def api_perform_lnurlauth(
return ""
@core_app.get("/api/v1/currencies")
@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())
class ConversionData(BaseModel):
from_: str = Field("sat", alias="from")
amount: float
to: str = Query("usd")
@core_app.post("/api/v1/conversion")
@api_router.post("/api/v1/conversion")
async def api_fiat_as_sats(data: ConversionData):
output = {}
if data.from_ == "sat":
@@ -697,8 +714,8 @@ async def api_fiat_as_sats(data: ConversionData):
return output
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(request: Request, data):
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(data):
qr = pyqrcode.create(data)
stream = BytesIO()
qr.svg(stream, scale=3)
@@ -718,31 +735,9 @@ async def img(request: Request, data):
)
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
async def api_auditor():
WALLET = get_wallet_class()
total_balance = await get_total_balance()
error_message, node_balance = await WALLET.status()
if not error_message:
delta = node_balance - total_balance
else:
node_balance, delta = 0, 0
return {
"node_balance_msats": int(node_balance),
"lnbits_balance_msats": int(total_balance),
"delta_msats": int(delta),
"timestamp": int(time.time()),
}
# UNIVERSAL WEBSOCKET MANAGER
@core_app.websocket("/api/v1/ws/{item_id}")
@api_router.websocket("/api/v1/ws/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocketManager.connect(websocket)
await websocketManager.connect(websocket, item_id)
try:
while True:
await websocket.receive_text()
@@ -750,25 +745,25 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
websocketManager.disconnect(websocket)
@core_app.post("/api/v1/ws/{item_id}")
@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:
except Exception:
return {"sent": False, "data": data}
@core_app.get("/api/v1/ws/{item_id}/{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:
except Exception:
return {"sent": False, "data": data}
@core_app.post("/api/v1/extension")
@api_router.post("/api/v1/extension")
async def api_install_extension(
data: CreateExtension, user: User = Depends(check_admin)
):
@@ -820,11 +815,14 @@ async def api_install_extension(
ext_info.clean_extension_files()
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to install extension {ext_info.id} ({ext_info.installed_version}).",
detail=(
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
)
@core_app.delete("/api/v1/extension/{ext_id}")
@api_router.delete("/api/v1/extension/{ext_id}")
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
installable_extensions = await InstallableExtension.get_installable_extensions()
@@ -843,7 +841,10 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
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.",
detail=(
f"Cannot uninstall. Extension '{installed_ext.name}' "
"depends on this one."
),
)
try:
@@ -864,7 +865,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
)
@core_app.get(
@api_router.get(
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
)
async def get_extension_releases(ext_id: str):
@@ -881,7 +882,7 @@ async def get_extension_releases(ext_id: str):
)
@core_app.get(
@api_router.get(
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],
)
@@ -902,10 +903,37 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
)
# TINYURL
@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}'",
)
@core_app.post("/api/v1/tinyurl")
@api_router.post(
"/api/v1/tinyurl",
name="Tinyurl",
description="creates a tinyurl",
)
async def api_create_tinyurl(
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
):
@@ -916,13 +944,17 @@ async def api_create_tinyurl(
if tinyurl.wallet == wallet.wallet.inkey:
return tinyurl
return await create_tinyurl(url, endless, wallet.wallet.inkey)
except:
except Exception:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
)
@core_app.get("/api/v1/tinyurl/{tinyurl_id}")
@api_router.get(
"/api/v1/tinyurl/{tinyurl_id}",
name="Tinyurl",
description="get a tinyurl by id",
)
async def api_get_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
):
@@ -934,13 +966,17 @@ async def api_get_tinyurl(
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
)
except:
except Exception:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
)
@core_app.delete("/api/v1/tinyurl/{tinyurl_id}")
@api_router.delete(
"/api/v1/tinyurl/{tinyurl_id}",
name="Tinyurl",
description="delete a tinyurl by id",
)
async def api_delete_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
):
@@ -953,22 +989,56 @@ async def api_delete_tinyurl(
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
)
except:
except Exception:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
)
@core_app.get("/t/{tinyurl_id}")
@api_router.get(
"/t/{tinyurl_id}",
name="Tinyurl",
description="redirects a tinyurl by id",
)
async def api_tinyurl(tinyurl_id: str):
try:
tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl:
response = RedirectResponse(url=tinyurl.url)
return response
else:
return
except:
tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl:
response = RedirectResponse(url=tinyurl.url)
return response
else:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
)
@api_router.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
async def api_create_webpush_subscription(
request: Request,
data: CreateWebPushSubscription,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> WebPushSubscription:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
wallet.wallet.user,
data.subscription,
host,
)
@api_router.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription(
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
)
await delete_webpush_subscription(endpoint, wallet.wallet.user)
+150 -119
View File
@@ -1,28 +1,30 @@
import asyncio
from http import HTTPStatus
from typing import List, Optional
from urllib.parse import urlparse
from fastapi import Depends, Query, Request, status
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.routing import APIRouter
from loguru import logger
from pydantic.types import UUID4
from starlette.responses import HTMLResponse, JSONResponse
from lnbits.core import db
from lnbits.core.db import db
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.settings import get_wallet_class, settings
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import currencies
from ..crud import (
create_account,
create_wallet,
delete_wallet,
get_balance_check,
get_dbversions,
get_inactive_extensions,
get_installed_extensions,
get_user,
@@ -32,22 +34,24 @@ from ..crud import (
)
from ..services import pay_invoice, redeem_lnurl_withdraw
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False
)
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return FileResponse("lnbits/core/static/favicon.ico")
@core_html_routes.get("/", response_class=HTMLResponse)
@generic_router.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning}
)
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
@generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots():
data = """
User-agent: *
@@ -56,7 +60,7 @@ async def robots():
return HTMLResponse(content=data, media_type="text/plain")
@core_html_routes.get(
@generic_router.get(
"/extensions", name="install.extensions", response_class=HTMLResponse
)
async def extensions_install(
@@ -113,6 +117,7 @@ async def extensions_install(
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions()
extensions = list(
map(
lambda ext: {
@@ -124,14 +129,16 @@ async def extensions_install(
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_extensions,
"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,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
},
installable_exts,
)
@@ -150,77 +157,55 @@ async def extensions_install(
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@core_html_routes.get(
@generic_router.get(
"/wallet",
response_class=HTMLResponse,
description="""
Args:
just **wallet_name**: create a new user, then create a new wallet for user with wallet_name<br>
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
nothing: create everything<br>
""",
description="show wallet page",
)
async def wallet(
request: Request = Query(None),
nme: Optional[str] = Query(None),
usr: Optional[UUID4] = Query(None),
request: Request,
usr: UUID4 = Query(...),
wal: Optional[UUID4] = Query(None),
):
user_id = usr.hex if usr else None
wallet_id = wal.hex if wal else None
wallet_name = nme
user_id = usr.hex
user = await get_user(user_id)
if not user_id:
new_user = await create_account()
user = await get_user(new_user.id)
assert user, "Newly created user has to exist."
logger.info(f"Create user {user.id}")
else:
user = await get_user(user_id)
if not user:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User does not exist."}
)
if (
len(settings.lnbits_allowed_users) > 0
and user_id not in settings.lnbits_allowed_users
and user_id not in settings.lnbits_admin_users
and user_id != settings.super_user
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."}
)
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
user.admin = True
if user_id == settings.super_user:
user.super_user = True
if not wallet_id:
if user.wallets and not wallet_name:
wallet = user.wallets[0]
else:
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
logger.info(
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
if not user:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User does not exist."}
)
logger.debug(
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
)
if not wal:
if len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={wallet.id}")
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={user.wallets[0].id}")
else:
wallet_id = wal.hex
userwallet = user.get_wallet(wallet_id)
if not userwallet:
if not userwallet or userwallet.deleted:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"}
)
if (
len(settings.lnbits_allowed_users) > 0
and user_id not in settings.lnbits_allowed_users
and user_id not in settings.lnbits_admin_users
and user_id != settings.super_user
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."}
)
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
user.admin = True
if user_id == settings.super_user:
user.super_user = True
logger.debug(f"Access user {user.id} wallet {userwallet.name}")
return template_renderer().TemplateResponse(
"core/wallet.html",
{
@@ -233,13 +218,21 @@ async def wallet(
)
@core_html_routes.get("/withdraw", response_class=JSONResponse)
@generic_router.get("/withdraw", response_class=JSONResponse)
async def lnurl_full_withdraw(request: Request):
user = await get_user(request.query_params.get("usr"))
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."}
wallet = user.get_wallet(request.query_params.get("wal"))
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."}
@@ -249,27 +242,39 @@ async def lnurl_full_withdraw(request: Request):
"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]}",
"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),
}
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
async def lnurl_full_withdraw_callback(request: Request):
user = await get_user(request.query_params.get("usr"))
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."}
wallet = user.get_wallet(request.query_params.get("wal"))
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:
except Exception:
pass
asyncio.create_task(pay())
@@ -281,40 +286,18 @@ async def lnurl_full_withdraw_callback(request: Request):
return {"status": "OK"}
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
async def deletewallet(wal: str = Query(...), usr: str = Query(...)):
user = await get_user(usr)
if not user:
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
user_wallet_ids = [u.id for u in user.wallets]
if wal not in user_wallet_ids:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
await delete_wallet(user_id=user.id, wallet_id=wal)
user_wallet_ids.remove(wal)
logger.debug("Deleted wallet {wal} of user {user.id}")
if user_wallet_ids:
return RedirectResponse(
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
return RedirectResponse(
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
@core_html_routes.get("/withdraw/notify/{service}")
@generic_router.get("/withdraw/notify/{service}")
async def lnurl_balance_notify(request: Request, service: str):
bc = await get_balance_check(request.query_params.get("wal"), service)
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)
@core_html_routes.get(
@generic_router.get(
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
)
async def lnurlwallet(request: Request):
@@ -324,10 +307,14 @@ async def lnurlwallet(request: Request):
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,
request.query_params.get("lightning"),
lightning_param,
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
@@ -340,13 +327,15 @@ async def lnurlwallet(request: Request):
)
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
@generic_router.get("/service-worker.js", response_class=FileResponse)
async def service_worker():
return FileResponse("lnbits/core/static/js/service-worker.js")
@core_html_routes.get("/manifest/{usr}.webmanifest")
async def manifest(usr: str):
@generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc
user = await get_user(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
@@ -356,9 +345,11 @@ async def manifest(usr: str):
"name": settings.lnbits_site_title + " Wallet",
"icons": [
{
"src": settings.lnbits_custom_logo
if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"src": (
settings.lnbits_custom_logo
if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png"
),
"type": "image/png",
"sizes": "900x900",
}
@@ -378,10 +369,49 @@ async def manifest(usr: str):
}
for wallet in user.wallets
],
"url_handlers": [{"origin": f"https://{host}"}],
}
@core_html_routes.get("/admin", response_class=HTMLResponse)
@generic_router.get("/node", response_class=HTMLResponse)
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()
return template_renderer().TemplateResponse(
"node/index.html",
{
"request": request,
"user": user.dict(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].dict(),
},
)
@generic_router.get("/node/public", response_class=HTMLResponse)
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()
return template_renderer().TemplateResponse(
"node/public.html",
{
"request": request,
"settings": settings.dict(),
"balance": balance,
},
)
@generic_router.get("/admin", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
@@ -396,11 +426,12 @@ async def index(request: Request, user: User = Depends(check_admin)):
"user": user.dict(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
},
)
@core_html_routes.get("/uuidv4/{hex_value}")
@generic_router.get("/uuidv4/{hex_value}")
async def hex_to_uuid4(hex_value: str):
try:
user_id = to_valid_user_id(hex_value).hex
+188
View File
@@ -0,0 +1,188 @@
from http import HTTPStatus
from typing import List, Optional
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from ...db import Filters, Page
from ...nodes.base import (
ChannelPoint,
Node,
NodeChannel,
NodeInfoResponse,
NodeInvoice,
NodeInvoiceFilters,
NodePayment,
NodePaymentsFilters,
NodePeerInfo,
PublicNodeInfo,
)
from ...utils.cache import cache
def require_node():
NODE = get_node_class()
if not NODE:
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API",
)
if not settings.lnbits_node_ui:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
return NODE
def check_public():
if not (settings.lnbits_node_ui and settings.lnbits_public_node_ui):
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
super_node_router = APIRouter(
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
)
public_node_router = APIRouter(
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
)
@node_router.get(
"/ok",
description="Check if node api can be enabled",
status_code=200,
dependencies=[Depends(require_node)],
)
async def api_get_ok():
pass
@public_node_router.get("/info", response_model=PublicNodeInfo)
async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeInfo:
return await cache.save_result(node.get_public_info, key="node:public_info")
@node_router.get("/info")
async def api_get_info(
node: Node = Depends(require_node),
) -> Optional[NodeInfoResponse]:
return await node.get_info()
@node_router.get("/channels")
async def api_get_channels(
node: Node = Depends(require_node),
) -> Optional[List[NodeChannel]]:
return await node.get_channels()
@super_node_router.post("/channels", response_model=ChannelPoint)
async def api_create_channel(
node: Node = Depends(require_node),
peer_id: str = Body(),
funding_amount: int = Body(),
push_amount: Optional[int] = Body(None),
fee_rate: Optional[int] = Body(None),
):
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
@super_node_router.delete("/channels")
async def api_delete_channel(
short_id: Optional[str],
funding_txid: Optional[str],
output_index: Optional[int],
force: bool = False,
node: Node = Depends(require_node),
) -> 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,
force,
)
@node_router.get("/payments", response_model=Page[NodePayment])
async def api_get_payments(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
) -> Optional[Page[NodePayment]]:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_payments(filters)
@node_router.get("/invoices", response_model=Page[NodeInvoice])
async def api_get_invoices(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
) -> Optional[Page[NodeInvoice]]:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_invoices(filters)
@node_router.get("/peers", response_model=List[NodePeerInfo])
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
return await node.get_peers()
@super_node_router.post("/peers")
async def api_connect_peer(
uri: str = Body(embed=True), node: Node = Depends(require_node)
):
return await node.connect_peer(uri)
@super_node_router.delete("/peers/{peer_id}")
async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
return await node.disconnect_peer(peer_id)
class NodeRank(BaseModel):
capacity: Optional[int]
channelcount: Optional[int]
age: Optional[int]
growth: Optional[int]
availability: Optional[int]
# Same for public and private api
@node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=Optional[NodeRank],
)
@public_node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=Optional[NodeRank],
)
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
node_id = await node.get_id()
async with httpx.AsyncClient() as client:
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
try:
r.raise_for_status()
return r.json()["noderank"]
except httpx.HTTPStatusError:
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
+6 -7
View File
@@ -1,18 +1,18 @@
import asyncio
import datetime
from http import HTTPStatus
from fastapi import HTTPException
from fastapi import APIRouter, HTTPException
from loguru import logger
from lnbits import bolt11
from .. import core_app
from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
public_router = APIRouter()
@core_app.get("/public/v1/payment/{payment_hash}")
@public_router.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
@@ -25,10 +25,9 @@ async def api_public_payment_longpolling(payment_hash):
try:
invoice = bolt11.decode(payment.bolt11)
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration < datetime.datetime.now():
if invoice.has_expired():
return {"status": "expired"}
except:
except Exception:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
)
+38 -52
View File
@@ -47,25 +47,22 @@ if settings.lnbits_database_url:
lambda value, curs: float(value) if value is not None else None,
)
)
register_type(
new_type(
(1082, 1083, 1266),
"DATE2INT",
lambda value, curs: time.mktime(value.timetuple())
if value is not None
else None,
)
)
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
else:
if os.path.isdir(settings.lnbits_data_folder):
DB_TYPE = SQLITE
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
logger.info(f"Created {settings.lnbits_data_folder}")
DB_TYPE = SQLITE
def compat_timestamp_placeholder():
if DB_TYPE == POSTGRES:
return "to_timestamp(?)"
elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)"
else:
raise NotADirectoryError(
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
)
return "?"
class Compat:
@@ -116,15 +113,9 @@ class Compat:
return "BIGINT"
return "INT"
@classmethod
@property
def timestamp_placeholder(cls):
if DB_TYPE == POSTGRES:
return "to_timestamp(?)"
elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)"
else:
return "?"
def timestamp_placeholder(self) -> str:
return compat_timestamp_placeholder()
class Connection(Compat):
@@ -303,6 +294,21 @@ class Database(Compat):
async def reuse_conn(self, conn: Connection):
yield conn
@classmethod
async def clean_ext_db_files(cls, ext_id: str) -> bool:
"""
If the extension DB is stored directly on the filesystem (like SQLite) then
delete the files and return True. Otherwise do nothing and return False.
"""
if DB_TYPE == SQLITE:
db_file = os.path.join(settings.lnbits_data_folder, f"ext_{ext_id}.sqlite3")
if os.path.isfile(db_file):
os.remove(db_file)
return True
return False
class Operator(Enum):
GT = "gt"
@@ -360,7 +366,6 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
field: str
nested: Optional[List[str]]
op: Operator = Operator.EQ
values: list[Any]
@@ -375,55 +380,36 @@ class Filter(BaseModel, Generic[TFilterModel]):
split = key[:-1].split("[")
if len(split) != 2:
raise ValueError("Invalid key")
field_names = split[0].split(".")
field = split[0]
op = Operator(split[1])
else:
field_names = key.split(".")
field = key
op = Operator("eq")
field = field_names[0]
nested = field_names[1:]
if field in model.__fields__:
compare_field = model.__fields__[field]
values = []
for raw_value in raw_values:
# If there is a nested field, pydantic expects a dict, so the raw value is turned into a dict before
# and the converted value is extracted afterwards
for name in reversed(nested):
raw_value = {name: raw_value}
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
for name in nested:
if isinstance(validated, dict):
validated = validated[name]
else:
validated = getattr(validated, name)
values.append(validated)
else:
raise ValueError("Unknown filter field")
return cls(field=field, op=op, nested=nested, values=values, model=model)
return cls(field=field, op=op, values=values, model=model)
@property
def statement(self):
accessor = self.field
if self.nested:
for name in self.nested:
accessor = f"({accessor} ->> '{name}')"
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
placeholder = Compat.timestamp_placeholder
placeholder = compat_timestamp_placeholder()
else:
placeholder = "?"
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
placeholders = ", ".join([placeholder] * len(self.values))
stmt = [f"{accessor} {self.op.as_sql} ({placeholders})"]
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
else:
stmt = [f"{accessor} {self.op.as_sql} {placeholder}"] * len(self.values)
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
return " OR ".join(stmt)
@@ -432,8 +418,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
Generic helper class for filtering and sorting data.
For usage in an api endpoint, use the `parse_filters` dependency.
When constructing this class manually always make sure to pass a model so that the values can be validated.
Otherwise, make sure to validate the inputs manually.
When constructing this class manually always make sure to pass a model so that
the values can be validated. Otherwise, make sure to validate the inputs manually.
"""
filters: List[Filter[TFilterModel]] = []
@@ -476,7 +462,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
if self.search and self.model:
if DB_TYPE == POSTGRES:
where_stmts.append(
f"lower(concat({f', '.join(self.model.__search_fields__)})) LIKE ?"
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
elif DB_TYPE == SQLITE:
where_stmts.append(
+31 -46
View File
@@ -1,7 +1,7 @@
from http import HTTPStatus
from typing import Literal, Optional, Type
from fastapi import Query, Request, Security, status
from fastapi import Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.security import APIKeyHeader, APIKeyQuery
@@ -9,7 +9,7 @@ from fastapi.security.base import SecurityBase
from pydantic.types import UUID4
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.core.models import User, Wallet
from lnbits.core.models import User, WalletType, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.requestvars import g
from lnbits.settings import settings
@@ -25,7 +25,7 @@ class KeyChecker(SecurityBase):
):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
self._key_type = "invoice"
self._key_type = WalletType.invoice
self._api_key = api_key
if api_key:
key = APIKey(
@@ -39,7 +39,7 @@ class KeyChecker(SecurityBase):
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.wallet = None # type: ignore
self.wallet = None
self.model: APIKey = key
async def __call__(self, request: Request):
@@ -49,16 +49,16 @@ class KeyChecker(SecurityBase):
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
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
if not self.wallet:
# 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 expired key.",
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."
@@ -81,7 +81,7 @@ class WalletInvoiceKeyChecker(KeyChecker):
api_key: Optional[str] = None,
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "invoice"
self._key_type = WalletType.invoice
class WalletAdminKeyChecker(KeyChecker):
@@ -100,16 +100,7 @@ class WalletAdminKeyChecker(KeyChecker):
api_key: Optional[str] = None,
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "admin"
class WalletTypeInfo:
wallet_type: int
wallet: Wallet
def __init__(self, wallet_type: int, wallet: Wallet) -> None:
self.wallet_type = wallet_type
self.wallet = wallet
self._key_type = WalletType.admin
api_key_header = APIKeyHeader(
@@ -129,11 +120,6 @@ async def get_key_type(
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
# 0: admin
# 1: invoice
# 2: invalid
pathname = r["path"].split("/")[1]
token = api_key_header or api_key_query
if not token:
@@ -142,38 +128,40 @@ async def get_key_type(
detail="Invoice (or Admin) key required.",
)
for typenr, WalletChecker in zip(
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
for wallet_type, WalletChecker in zip(
[WalletType.admin, WalletType.invoice],
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
):
try:
checker = WalletChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
if wallet is None or wallet.wallet is None:
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 pathname in 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 e:
if e.status_code == HTTPStatus.BAD_REQUEST:
except HTTPException as exc:
if exc.status_code == HTTPStatus.BAD_REQUEST:
raise
elif e.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
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:
except Exception:
raise
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
@@ -199,7 +187,7 @@ async def require_admin_key(
# 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=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
)
else:
return wallet
@@ -220,11 +208,12 @@ async def require_invoice_key(
wallet = await get_key_type(r, token)
if wallet.wallet_type > 1:
# If wallet type is not invoice then return the unauthorized status
# This also covers when the user passes an invalid key type
if (
wallet.wallet_type != WalletType.admin
and wallet.wallet_type != WalletType.invoice
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
else:
@@ -259,10 +248,6 @@ async def check_admin(usr: UUID4) -> User:
status_code=HTTPStatus.UNAUTHORIZED,
detail="User not authorized. No admin privileges.",
)
user.admin = True
user.super_user = False
if user.id == settings.super_user:
user.super_user = True
return user
+55 -36
View File
@@ -25,6 +25,7 @@ class ExplicitRelease(BaseModel):
archive: str
hash: str
dependencies: List[str] = []
repo: Optional[str]
icon: Optional[str]
short_description: Optional[str]
min_lnbits_version: Optional[str]
@@ -98,18 +99,18 @@ async def fetch_github_repo_info(
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await gihub_api_get(repo_url, error_msg)
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await gihub_api_get(lates_release_url, error_msg)
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await gihub_api_get(config_url, error_msg)
config = await github_api_get(config_url, error_msg)
return (
github_repo,
@@ -120,14 +121,14 @@ async def fetch_github_repo_info(
async def fetch_manifest(url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await gihub_api_get(url, error_msg)
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await gihub_api_get(releases_url, error_msg)
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@@ -138,11 +139,11 @@ async def fetch_github_release_config(
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await gihub_api_get(config_url, error_msg)
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
async def gihub_api_get(url: str, error_msg: Optional[str]) -> Any:
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
async with httpx.AsyncClient() as client:
headers = (
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
@@ -181,12 +182,19 @@ class Extension(NamedTuple):
upgrade_hash: Optional[str] = ""
@property
def module_name(self):
return (
f"lnbits.extensions.{self.code}"
if self.upgrade_hash == ""
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
)
def module_name(self) -> str:
if self.is_upgrade_extension:
if settings.has_default_extension_path:
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
return f"{self.code}-{self.upgrade_hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}"
return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
@@ -203,8 +211,8 @@ class Extension(NamedTuple):
class ExtensionManager:
def __init__(self):
p = Path(settings.lnbits_path, "extensions")
def __init__(self) -> None:
p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
@@ -254,6 +262,7 @@ class ExtensionRelease(BaseModel):
html_url: Optional[str] = None
description: Optional[str] = None
warning: Optional[str] = None
repo: Optional[str] = None
icon: Optional[str] = None
@classmethod
@@ -267,7 +276,7 @@ class ExtensionRelease(BaseModel):
archive=r.zipball_url,
source_repo=source_repo,
is_github_release=True,
# description=r.body, # bad for JSON
repo=f"https://github.com/{source_repo}",
html_url=r.html_url,
)
@@ -286,6 +295,7 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
repo=e.repo,
icon=e.icon,
)
@@ -327,21 +337,25 @@ class InstallableExtension(BaseModel):
@property
def zip_path(self) -> Path:
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions")
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip")
@property
def ext_dir(self) -> Path:
return Path(settings.lnbits_path, "extensions", self.id)
return Path(settings.lnbits_extensions_path, "extensions", self.id)
@property
def ext_upgrade_dir(self) -> Path:
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}")
return Path(
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
)
@property
def module_name(self) -> str:
return f"lnbits.extensions.{self.id}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}"
return self.id
@property
def module_installed(self) -> bool:
@@ -386,21 +400,26 @@ class InstallableExtension(BaseModel):
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
shutil.rmtree(self.ext_upgrade_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(self.ext_upgrade_dir)
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
os.rename(
Path(self.ext_upgrade_dir, generated_dir_name),
Path(self.ext_upgrade_dir, self.id),
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
parents=True, exist_ok=True
)
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
shutil.rmtree(self.ext_upgrade_dir, True)
shutil.copytree(
Path(tmp_dir, generated_dir_name),
Path(self.ext_upgrade_dir),
)
shutil.rmtree(tmp_dir, True)
# Pre-packed extensions can be upgraded
# Mark the extension as installed so we know it is not the pre-packed version
with open(
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
) as json_file:
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
config_json = json.load(json_file)
self.name = config_json.get("name")
@@ -416,14 +435,14 @@ class InstallableExtension(BaseModel):
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(
Path(self.ext_upgrade_dir, self.id),
Path(settings.lnbits_path, "extensions", self.id),
)
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:
"""Update the list of upgraded extensions. The middleware will perform redirects based on this"""
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
clean_upgraded_exts = list(
filter(
View File
+18 -19
View File
@@ -4,13 +4,10 @@ from typing import Any, List, Optional, Type
import jinja2
import shortuuid
from pydantic.schema import (
field_schema,
get_flat_models_from_fields,
get_model_name_map,
)
from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
from lnbits.settings import settings
@@ -34,6 +31,10 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders:
additional_folders += [
Path(settings.lnbits_extensions_path, "extensions", f)
for f in additional_folders
]
folders.extend(additional_folders)
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
@@ -48,9 +49,12 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
t.env.globals["LNBITS_VERSION"] = settings.version
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
t.env.globals["LNBITS_NODE_UI"] = (
settings.lnbits_node_ui and get_node_class() is not None
)
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = [
e
for e in get_valid_extensions()
@@ -69,6 +73,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
return t
@@ -90,32 +96,25 @@ def get_current_extension_name() -> str:
with open(config_path) as json_file:
config = json.load(json_file)
ext_name = config["name"]
except:
except Exception:
ext_name = extension_director_name
return ext_name
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
"""
Generate openapi documentation for Filters. This is intended to be used along parse_filters (see example)
Generate openapi documentation for Filters. This is intended to be used along
parse_filters (see example)
:param model: Filter model
:param keep_optional: If false, all parameters will be optional, otherwise inferred from model
:param keep_optional: If false, all parameters will be optional,
otherwise inferred from model
"""
fields = list(model.__fields__.values())
models = get_flat_models_from_fields(fields, set())
namemap = get_model_name_map(models)
params = []
for field in fields:
schema, definitions, _ = field_schema(field, model_name_map=namemap)
# Support nested definition
if "$ref" in schema:
name = schema["$ref"].split("/")[-1]
schema = definitions[name]
schema, _, _ = field_schema(field, model_name_map={})
description = "Supports Filtering"
if schema["type"] == "object":
description += f". Nested attributes can be filtered too, e.g. `{field.alias}.[additional].[attributes]`"
if (
hasattr(model, "__search_fields__")
and field.name in model.__search_fields__
+5 -2
View File
@@ -1,4 +1,7 @@
from typing import Union
from bech32 import bech32_decode, bech32_encode, convertbits
from fastapi.datastructures import URL
def decode(lnurl: str) -> str:
@@ -10,8 +13,8 @@ def decode(lnurl: str) -> str:
return bytes(bech32_data).decode()
def encode(url: str) -> str:
bech32_data = convertbits(url.encode(), 8, 5, True)
def encode(url: Union[str, URL]) -> str:
bech32_data = convertbits(str(url).encode(), 8, 5, True)
assert bech32_data
lnurl = bech32_encode("lnurl", bech32_data)
return lnurl.upper()
+62 -6
View File
@@ -2,9 +2,15 @@ from http import HTTPStatus
from typing import Any, List, Tuple, Union
from urllib.parse import parse_qs
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
from lnbits.core.db import core_app_extra
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -12,7 +18,8 @@ from lnbits.settings import settings
class InstalledExtensionMiddleware:
# This middleware class intercepts calls made to the extensions API and:
# - it blocks the calls if the extension has been disabled or uninstalled.
# - it redirects the calls to the latest version of the extension if the extension has been upgraded.
# - it redirects the calls to the latest version of the extension
# if the extension has been upgraded.
# - otherwise it has no effect
def __init__(self, app: ASGIApp) -> None:
self.app = app
@@ -83,9 +90,10 @@ class InstalledExtensionMiddleware:
self, 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` as HTTP code.
If the `accept` HTTP header is present int the request and contains the value of `text/html`
then return an `HTMLResponse`, otherwise return an `JSONResponse`.
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
as HTTP code. If the `accept` HTTP header is present int the request and
contains the value of `text/html` then return an `HTMLResponse`,
otherwise return an `JSONResponse`.
"""
accept_header: str = next(
(
@@ -110,9 +118,21 @@ class InstalledExtensionMiddleware:
)
class CustomGZipMiddleware(GZipMiddleware):
def __init__(self, *args, exclude_paths=None, **kwargs):
super().__init__(*args, **kwargs)
self.exclude_paths = exclude_paths or []
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if "path" in scope and scope["path"] in self.exclude_paths:
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
class ExtensionsRedirectMiddleware:
# Extensions are allowed to specify redirect paths.
# A call to a path outside the scope of the extension can be redirected to one of the extension's endpoints.
# Extensions are allowed to specify redirect paths. A call to a path outside the
# scope of the extension can be redirected to one of the extension's endpoints.
# Eg: redirect `GET /.well-known` to `GET /lnurlp/api/v1/well-known`
def __init__(self, app: ASGIApp) -> None:
@@ -189,3 +209,39 @@ class ExtensionsRedirectMiddleware:
]
return "/" + "/".join(elements)
def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter()
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
def add_ip_block_middleware(app: FastAPI):
@app.middleware("http")
async def block_allow_ip_middleware(request: Request, call_next):
if not request.client:
return JSONResponse(
status_code=403, # Forbidden
content={"detail": "No request client"},
)
if (
request.client.host in settings.lnbits_blocked_ips
and request.client.host not in settings.lnbits_allowed_ips
):
return JSONResponse(
status_code=403, # Forbidden
content={"detail": "IP is blocked"},
)
# this try: except: block is not needed on latest FastAPI
# (await call_next(request) is enough)
# https://stackoverflow.com/questions/71222144/runtimeerror-no-response-returned-in-fastapi-when-refresh-request
# TODO: remove after https://github.com/lnbits/lnbits/pull/1609 is merged
try:
return await call_next(request)
except RuntimeError as exc:
if str(exc) == "No response returned." and await request.is_disconnected():
return Response(status_code=HTTPStatus.NO_CONTENT)
raise # bubble up different exceptions
app.middleware("http")(block_allow_ip_middleware)
+15
View File
@@ -0,0 +1,15 @@
from typing import Optional
from .base import Node
def get_node_class() -> Optional[Node]:
return NODE
def set_node_class(node: Node):
global NODE
NODE = node
NODE: Optional[Node] = None
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from pydantic import BaseModel
from lnbits.db import FilterModel, Filters, Page
from lnbits.utils.cache import cache
if TYPE_CHECKING:
from lnbits.wallets.base import Wallet
class NodePeerInfo(BaseModel):
id: str
alias: Optional[str] = None
color: Optional[str] = None
last_timestamp: Optional[int] = None
addresses: Optional[list[str]] = None
class ChannelState(Enum):
ACTIVE = "active"
PENDING = "pending"
CLOSED = "closed"
INACTIVE = "inactive"
class ChannelBalance(BaseModel):
local_msat: int
remote_msat: int
total_msat: int
class ChannelPoint(BaseModel):
funding_txid: str
output_index: int
class NodeChannel(BaseModel):
short_id: Optional[str] = None
point: Optional[ChannelPoint] = None
peer_id: str
balance: ChannelBalance
state: ChannelState
name: Optional[str]
color: Optional[str]
class ChannelStats(BaseModel):
counts: dict[ChannelState, int]
avg_size: int
biggest_size: Optional[int]
smallest_size: Optional[int]
total_capacity: int
@classmethod
def from_list(cls, channels: list[NodeChannel]):
counts: dict[ChannelState, int] = {}
for channel in channels:
counts[channel.state] = counts.get(channel.state, 0) + 1
return cls(
counts=counts,
avg_size=int(
sum(channel.balance.total_msat for channel in channels) / len(channels)
),
biggest_size=max(channel.balance.total_msat for channel in channels),
smallest_size=min(channel.balance.total_msat for channel in channels),
total_capacity=sum(channel.balance.total_msat for channel in channels),
)
class NodeFees(BaseModel):
total_msat: int
daily_msat: Optional[int] = None
weekly_msat: Optional[int] = None
monthly_msat: Optional[int] = None
class PublicNodeInfo(BaseModel):
id: str
backend_name: str
alias: str
color: str
num_peers: int
blockheight: int
channel_stats: ChannelStats
addresses: list[str]
class NodeInfoResponse(PublicNodeInfo):
onchain_balance_sat: int
onchain_confirmed_sat: int
fees: NodeFees
balance_msat: int
class NodePayment(BaseModel):
pending: bool
amount: int
fee: Optional[int] = None
memo: Optional[str] = None
time: int
bolt11: str
preimage: Optional[str]
payment_hash: str
expiry: Optional[float] = None
destination: Optional[NodePeerInfo] = None
class NodeInvoice(BaseModel):
pending: bool
amount: int
memo: Optional[str]
bolt11: str
preimage: Optional[str]
payment_hash: str
paid_at: Optional[int] = None
expiry: Optional[int] = None
class NodeInvoiceFilters(FilterModel):
pass
class NodePaymentsFilters(FilterModel):
pass
class Node(ABC):
wallet: Wallet
def __init__(self, wallet: Wallet):
self.wallet = wallet
self.id: Optional[str] = None
@property
def name(self):
return self.__class__.__name__
async def get_id(self):
if not self.id:
self.id = await self._get_id()
return self.id
@abstractmethod
async def _get_id(self) -> str:
pass
async def get_peers(self) -> list[NodePeerInfo]:
peer_ids = await self.get_peer_ids()
return [await self.get_peer_info(peer_id) for peer_id in peer_ids]
@abstractmethod
async def get_peer_ids(self) -> list[str]:
pass
@abstractmethod
async def connect_peer(self, uri: str):
pass
@abstractmethod
async def disconnect_peer(self, peer_id: str):
pass
@abstractmethod
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
pass
async def get_peer_info(self, peer_id: str) -> NodePeerInfo:
key = f"node:peers:{peer_id}"
info = cache.get(key)
if not info:
info = await self._get_peer_info(peer_id)
if info.last_timestamp:
cache.set(key, info)
return info
@abstractmethod
async def open_channel(
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
) -> ChannelPoint:
pass
@abstractmethod
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
force: bool = False,
):
pass
@abstractmethod
async def get_channels(self) -> List[NodeChannel]:
pass
@abstractmethod
async def get_info(self) -> NodeInfoResponse:
pass
async def get_public_info(self) -> PublicNodeInfo:
info = await self.get_info()
return PublicNodeInfo(**info.__dict__)
@abstractmethod
async def get_payments(
self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]:
pass
@abstractmethod
async def get_invoices(
self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]:
pass
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
import asyncio
from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException
from lnbits.db import Filters, Page
from ..utils.cache import cache
try:
from pyln.client import RpcError # type: ignore
if TYPE_CHECKING:
# override the false type
class RpcError(RpcError): # type: ignore
error: dict
except ImportError: # pragma: nocover
LightningRpc = None
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeFees,
NodeInvoice,
NodeInvoiceFilters,
NodePaymentsFilters,
NodePeerInfo,
)
from .base import NodeChannel, NodeInfoResponse, NodePayment
if TYPE_CHECKING:
from lnbits.wallets import CoreLightningWallet
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"])
else:
raise HTTPException(status_code=500, detail=e.error["message"])
return wrapper
class CoreLightningNode(Node):
wallet: CoreLightningWallet
async def ln_rpc(self, method, *args, **kwargs) -> dict:
loop = asyncio.get_event_loop()
fn = getattr(self.wallet.ln, method)
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
@catch_rpc_errors
async def connect_peer(self, uri: str):
# 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"])
else:
raise
@catch_rpc_errors
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:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
detail=e.error["message"],
)
else:
raise
@catch_rpc_errors
async def open_channel(
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
) -> ChannelPoint:
try:
result = await self.ln_rpc(
"fundchannel",
peer_id,
amount=local_amount,
push_msat=int(push_amount * 1000) if push_amount else None,
feerate=fee_rate,
)
return ChannelPoint(
funding_txid=result["txid"],
output_index=result["outnum"],
)
except RpcError as e:
message = e.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.",
)
if "Unknown peer" in message:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(
"We where able to connect to the peer but CLN "
"can't find it when opening a channel."
),
)
if "Owning subdaemon openingd died" in message:
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
raise HTTPException(
HTTPStatus.BAD_REQUEST,
detail=(
"Likely the peer didn't like our channel opening "
"proposal and disconnected from us."
),
)
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
@catch_rpc_errors
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
force: bool = False,
):
if not short_id:
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"]
if (
"Short channel ID not active:" in message
or "Short channel ID not found" in message
):
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
else:
raise
@catch_rpc_errors
async def _get_id(self) -> str:
info = await self.ln_rpc("getinfo")
return info["id"]
@catch_rpc_errors
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"]]
@catch_rpc_errors
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
result = await self.ln_rpc("listnodes", peer_id)
nodes = result["nodes"]
if len(nodes) == 0:
return NodePeerInfo(id=peer_id)
node = nodes[0]
if "last_timestamp" in node:
return NodePeerInfo(
id=node["nodeid"],
alias=node["alias"],
color=node["color"],
last_timestamp=node["last_timestamp"],
addresses=[
address["address"] + ":" + str(address["port"])
for address in node["addresses"]
],
)
else:
return NodePeerInfo(id=node["nodeid"])
@catch_rpc_errors
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"]}
return [
NodeChannel(
short_id=ch.get("short_channel_id"),
point=ChannelPoint(
funding_txid=ch["funding_txid"],
output_index=ch["funding_output"],
),
peer_id=ch["peer_id"],
balance=ChannelBalance(
local_msat=ch["our_amount_msat"],
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
total_msat=ch["amount_msat"],
),
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
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.INACTIVE
),
)
for ch in funds["channels"]
]
@catch_rpc_errors
async def get_info(self) -> NodeInfoResponse:
info = await self.ln_rpc("getinfo")
funds = await self.ln_rpc("listfunds")
channels = await self.get_channels()
active_channels = [
channel for channel in channels if channel.state == ChannelState.ACTIVE
]
return NodeInfoResponse(
id=info["id"],
backend_name="CLN",
alias=info["alias"],
color=info["color"],
onchain_balance_sat=sum(output["value"] for output in funds["outputs"]),
onchain_confirmed_sat=sum(
output["amount_msat"] / 1000
for output in funds["outputs"]
if output["status"] == "confirmed"
),
channel_stats=ChannelStats.from_list(channels),
num_peers=info["num_peers"],
blockheight=info["blockheight"],
balance_msat=sum(channel.balance.local_msat for channel in active_channels),
fees=NodeFees(total_msat=info["fees_collected_msat"]),
addresses=[address["address"] for address in info["address"]],
)
@catch_rpc_errors
async def get_payments(
self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]:
async def get_payments():
result = await self.ln_rpc("listpays")
return [
NodePayment(
bolt11=pay["bolt11"],
amount=pay["amount_msat"],
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
memo=pay.get("description"),
time=pay["created_at"],
preimage=pay.get("preimage"),
payment_hash=pay["payment_hash"],
pending=pay["status"] != "complete",
destination=await self.get_peer_info(pay["destination"]),
)
for pay in reversed(result["pays"])
if pay["status"] != "failed"
]
results = await cache.save_result(get_payments, key="node:payments")
count = len(results)
if filters.offset:
results = results[filters.offset :]
if filters.limit:
results = results[: filters.limit]
return Page(data=results, total=count)
@catch_rpc_errors
async def get_invoices(
self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]:
result = await cache.save_result(
lambda: self.ln_rpc("listinvoices"), key="node:invoices"
)
invoices = result["invoices"]
invoices.reverse()
count = len(invoices)
if filters.offset:
invoices = invoices[filters.offset :]
if filters.limit:
invoices = invoices[: filters.limit]
return Page(
data=[
NodeInvoice(
bolt11=invoice["bolt11"],
amount=invoice["amount_msat"],
preimage=invoice.get("payment_preimage"),
memo=invoice["description"],
paid_at=invoice.get("paid_at"),
expiry=invoice["expires_at"],
payment_hash=invoice["payment_hash"],
pending=invoice["status"] != "paid",
)
for invoice in invoices
],
total=count,
)
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
import asyncio
import base64
import json
from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException
from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodeInvoiceFilters,
NodePayment,
NodePaymentsFilters,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.utils.cache import cache
if TYPE_CHECKING:
from lnbits.wallets import LndRestWallet
def msat(raw: str) -> int:
return int(raw) * 1000
def _decode_bytes(data: str) -> str:
return base64.b64decode(data).hex()
def _parse_channel_point(raw: str) -> ChannelPoint:
funding_tx, output_index = raw.split(":")
return ChannelPoint(
funding_txid=funding_tx,
output_index=int(output_index),
)
class LndRestNode(Node):
wallet: LndRestWallet
async def request(
self, method: str, path: str, json: Optional[dict] = None, **kwargs
):
response = await self.wallet.client.request(
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
)
try:
response.raise_for_status()
except HTTPStatusError as e:
json = e.response.json()
if json:
error = json.get("error") or json
raise HTTPException(e.response.status_code, detail=error.get("message"))
return response.json()
def get(self, path: str, **kwargs):
return self.request("GET", path, **kwargs)
async def _get_id(self) -> str:
info = await self.get("/v1/getinfo")
return info["identity_pubkey"]
async def get_peer_ids(self) -> list[str]:
response = await self.get("/v1/peers")
return [p["pub_key"] for p in response["peers"]]
async def connect_peer(self, uri: str):
try:
pubkey, host = uri.split("@")
except ValueError:
raise HTTPException(400, detail="Invalid peer URI")
await self.request(
"POST",
"/v1/peers",
json={
"addr": {"pubkey": pubkey, "host": host},
"perm": True,
"timeout": 30,
},
)
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:
raise HTTPException(
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
)
raise
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
try:
response = await self.get("/v1/graph/node/" + peer_id)
except HTTPException:
return NodePeerInfo(id=peer_id)
node = response["node"]
return NodePeerInfo(
id=peer_id,
alias=node["alias"],
color=node["color"].strip("#"),
last_timestamp=node["last_update"],
addresses=[a["addr"] for a in node["addresses"]],
)
async def open_channel(
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
) -> ChannelPoint:
response = await self.request(
"POST",
"/v1/channels",
data=json.dumps(
{
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
"node_pubkey_string": peer_id,
"sat_per_vbyte": fee_rate,
"local_funding_amount": local_amount,
"push_sat": push_amount,
}
),
)
return ChannelPoint(
# WHY IS THIS REVERSED?!
funding_txid=bytes(
reversed(base64.b64decode(response["funding_txid_bytes"]))
).hex(),
output_index=response["output_index"],
)
async def _close_channel(
self,
point: ChannelPoint,
force: bool = False,
):
async with self.wallet.client.stream(
"DELETE",
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
params={"force": force},
timeout=None,
) as stream:
async for chunk in stream.aiter_text():
if chunk:
chunk = json.loads(chunk)
logger.info(f"LND Channel close update: {chunk['result']}")
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
force: bool = False,
):
if not point:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
)
asyncio.create_task(self._close_channel(point, force))
async def get_channels(self) -> List[NodeChannel]:
normal, pending, closed = await asyncio.gather(
self.get("/v1/channels"),
self.get("/v1/channels/pending"),
self.get("/v1/channels/closed"),
)
channels = []
async def parse_pending(raw_channels, state):
for channel in raw_channels:
channel = channel["channel"]
info = await self.get_peer_info(channel["remote_node_pub"])
channels.append(
NodeChannel(
peer_id=info.id,
state=state,
name=info.alias,
color=info.color,
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
)
)
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
await parse_pending(
pending["pending_force_closing_channels"], ChannelState.CLOSED
)
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
for channel in closed["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
peer_id=info.id,
state=ChannelState.CLOSED,
name=info.alias,
color=info.color,
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=0,
remote_msat=0,
total_msat=msat(channel["capacity"]),
),
)
)
for channel in normal["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
short_id=channel["chan_id"],
point=_parse_channel_point(channel["channel_point"]),
peer_id=channel["remote_pubkey"],
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
state=ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE,
# name=channel['peer_alias'],
name=info.alias,
color=info.color,
)
)
return channels
async def get_public_info(self) -> PublicNodeInfo:
info = await self.get("/v1/getinfo")
channels = await self.get_channels()
return PublicNodeInfo(
backend_name="LND",
id=info["identity_pubkey"],
color=info["color"].lstrip("#"),
alias=info["alias"],
num_peers=info["num_peers"],
blockheight=info["block_height"],
addresses=info["uris"],
channel_stats=ChannelStats.from_list(channels),
)
async def get_info(self) -> NodeInfoResponse:
public = await self.get_public_info()
onchain = await self.get("/v1/balance/blockchain")
fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels")
return NodeInfoResponse(
**public.dict(),
onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"],
fees=NodeFees(
total_msat=0,
daily_msat=fee_report["day_fee_sum"],
weekly_msat=fee_report["week_fee_sum"],
monthly_msat=fee_report["month_fee_sum"],
),
)
async def get_payments(
self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]:
count_key = "node:payments_count"
payments_count = cache.get(count_key)
if not payments_count and filters.offset:
# this forces fetching the payments count
await self.get_payments(Filters(limit=1))
payments_count = cache.get(count_key)
if filters.offset and payments_count:
index_offset = max(payments_count + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/payments",
params={
"index_offset": index_offset,
"max_payments": filters.limit,
"include_incomplete": True,
"reversed": True,
"count_total_payments": not index_offset,
},
)
if not filters.offset:
payments_count = int(response["total_num_payments"])
cache.set(count_key, payments_count)
payments = [
NodePayment(
payment_hash=payment["payment_hash"],
pending=payment["status"] == "IN_FLIGHT",
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,
bolt11=payment["payment_request"],
preimage=payment["payment_preimage"],
)
for payment in response["payments"]
]
payments.sort(key=lambda p: p.time, reverse=True)
return Page(data=payments, total=payments_count or 0)
async def get_invoices(
self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]:
last_invoice_key = "node:last_invoice_index"
last_invoice_index = cache.get(last_invoice_key)
if not last_invoice_index and filters.offset:
# this forces fetching the last invoice index so
await self.get_invoices(Filters(limit=1))
last_invoice_index = cache.get(last_invoice_key)
if filters.offset and last_invoice_index:
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/invoices",
params={
"index_offset": index_offset,
"num_max_invoices": filters.limit,
"reversed": True,
},
)
if not filters.offset:
last_invoice_index = int(response["last_index_offset"])
cache.set(last_invoice_key, last_invoice_index)
invoices = [
NodeInvoice(
payment_hash=_decode_bytes(invoice["r_hash"]),
amount=invoice["value_msat"],
memo=invoice["memo"],
pending=invoice["state"] == "OPEN",
paid_at=invoice["settle_date"],
expiry=invoice["creation_date"] + invoice["expiry"],
preimage=_decode_bytes(invoice["r_preimage"]),
bolt11=invoice["payment_request"],
)
for invoice in reversed(response["invoices"])
]
return Page(
data=invoices,
total=last_invoice_index or 0,
)
+16 -6
View File
@@ -1,13 +1,10 @@
import uvloop
uvloop.install()
import multiprocessing as mp
import time
from pathlib import Path
import click
import uvicorn
from uvicorn.supervisors import ChangeReload
from lnbits.settings import set_cli_settings, settings
@@ -41,9 +38,15 @@ def main(
# create data dir if it does not exist
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
# create `extensions`` dir if it does not exist
Path(settings.lnbits_extensions_path, "extensions").mkdir(
parents=True, exist_ok=True
)
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
# 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("=")
@@ -70,7 +73,14 @@ def main(
)
server = uvicorn.Server(config=config)
process = mp.Process(target=server.run)
if config.should_reload:
sock = config.bind_socket()
run = ChangeReload(config, target=server.run, sockets=[sock]).run
else:
run = server.run
process = mp.Process(target=run)
process.start()
server_restart.wait()
server_restart.clear()
+96 -55
View File
@@ -1,42 +1,36 @@
from __future__ import annotations
import importlib
import importlib.metadata
import inspect
import json
import subprocess
from os import path
from sqlite3 import Row
from typing import Any, List, Optional
import httpx
from loguru import logger
from pydantic import BaseSettings, Extra, Field, validator
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
def list_parse_fallback(v):
try:
return json.loads(v)
except Exception:
replaced = v.replace(" ", "")
if replaced:
return replaced.split(",")
def list_parse_fallback(v: str):
v = v.replace(" ", "")
if len(v) > 0:
if v.startswith("[") or v.startswith("{"):
return json.loads(v)
else:
return []
return v.split(",")
else:
return []
class LNbitsSettings(BaseSettings):
class LNbitsSettings(BaseModel):
@classmethod
def validate(cls, val):
if type(val) == str:
def validate_list(cls, val):
if isinstance(val, str):
val = val.split(",") if val else []
return val
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
extra = Extra.ignore
class UsersSettings(LNbitsSettings):
lnbits_admin_users: List[str] = Field(default=[])
@@ -89,6 +83,8 @@ class ThemesSettings(LNbitsSettings):
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: List[str] = Field(default=[])
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
class OpsSettings(LNbitsSettings):
@@ -100,6 +96,24 @@ class OpsSettings(LNbitsSettings):
lnbits_denomination: str = Field(default="sats")
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_notifications: bool = Field(default=False)
lnbits_killswitch: bool = Field(default=False)
lnbits_killswitch_interval: int = Field(default=60)
lnbits_watchdog: bool = Field(default=False)
lnbits_watchdog_interval: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000)
lnbits_status_manifest: str = Field(
default=(
"https://raw.githubusercontent.com/lnbits/lnbits-status/main/manifest.json"
)
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -120,6 +134,12 @@ class CoreLightningFundingSource(LNbitsSettings):
clightning_rpc: Optional[str] = Field(default=None)
class CoreLightningRestFundingSource(LNbitsSettings):
corelightning_rest_url: Optional[str] = Field(default=None)
corelightning_rest_macaroon: Optional[str] = Field(default=None)
corelightning_rest_cert: Optional[str] = Field(default=None)
class EclairFundingSource(LNbitsSettings):
eclair_url: Optional[str] = Field(default=None)
eclair_pass: Optional[str] = Field(default=None)
@@ -182,7 +202,7 @@ class BoltzExtensionSettings(LNbitsSettings):
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=600)
lightning_invoice_expiry: int = Field(default=3600)
class FundingSourcesSettings(
@@ -190,6 +210,7 @@ class FundingSourcesSettings(
LNbitsFundingSource,
ClicheFundingSource,
CoreLightningFundingSource,
CoreLightningRestFundingSource,
EclairFundingSource,
LndRestFundingSource,
LndGrpcFundingSource,
@@ -201,14 +222,32 @@ class FundingSourcesSettings(
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str = Field(default=None)
lnbits_webpush_privkey: str = Field(default=None)
class NodeUISettings(LNbitsSettings):
# on-off switch for node ui
lnbits_node_ui: bool = Field(default=False)
# whether to display the public node ui (only if lnbits_node_ui is True)
lnbits_public_node_ui: bool = Field(default=False)
# can be used to disable the transactions tab in the node ui
# (recommended for large cln nodes)
lnbits_node_ui_transactions: bool = Field(default=False)
class EditableSettings(
UsersSettings,
ExtensionsSettings,
ThemesSettings,
OpsSettings,
SecuritySettings,
FundingSourcesSettings,
BoltzExtensionSettings,
LightningSettings,
WebPushSettings,
NodeUISettings,
):
@validator(
"lnbits_admin_users",
@@ -219,7 +258,7 @@ class EditableSettings(
)
@classmethod
def validate_editable_settings(cls, val):
return super().validate(val)
return super().validate_list(val)
@classmethod
def from_dict(cls, d: dict):
@@ -227,6 +266,18 @@ class EditableSettings(
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
)
# fixes openapi.json validation, remove field env_names
class Config:
@staticmethod
def schema_extra(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
class UpdateSettings(EditableSettings):
class Config:
extra = Extra.forbid
class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
@@ -234,11 +285,16 @@ class EnvSettings(LNbitsSettings):
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000)
forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
lnbits_commit: str = Field(default="unknown")
lnbits_extensions_path: str = Field(default="lnbits")
super_user: str = Field(default="")
version: str = Field(default="0.0.0")
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: Optional[str] = Field(default=None)
@@ -257,6 +313,7 @@ class SuperUserSettings(LNbitsSettings):
"VoidWallet",
"FakeWallet",
"CoreLightningWallet",
"CoreLightningRestWallet",
"LndRestWallet",
"EclairWallet",
"LndWallet",
@@ -295,19 +352,25 @@ class ReadOnlySettings(
)
@classmethod
def validate_readonly_settings(cls, val):
return super().validate(val)
return super().validate_list(val)
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
class Settings(EditableSettings, ReadOnlySettings, TransientSettings):
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> "Settings":
data = dict(row)
return cls(**data)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
class SuperSettings(EditableSettings):
super_user: str
@@ -323,19 +386,6 @@ def set_cli_settings(**kwargs):
setattr(settings, key, value)
# set wallet class after settings are loaded
def set_wallet_class(class_name: Optional[str] = None):
backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
wallet_class = getattr(wallets_module, backend_wallet_class)
global WALLET
WALLET = wallet_class()
def get_wallet_class():
# wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
return WALLET
def send_admin_user_to_saas():
if settings.lnbits_saas_callback:
with httpx.Client() as client:
@@ -356,7 +406,8 @@ def send_admin_user_to_saas():
logger.success("sent super_user to saas application")
except Exception as e:
logger.error(
f"error sending super_user to saas: {settings.lnbits_saas_callback}. Error: {str(e)}"
"error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {str(e)}"
)
@@ -367,18 +418,6 @@ settings = Settings()
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
try:
settings.lnbits_commit = (
subprocess.check_output(
["git", "-C", settings.lnbits_path, "rev-parse", "HEAD"],
stderr=subprocess.DEVNULL,
)
.strip()
.decode("ascii")
)
except:
settings.lnbits_commit = "docker"
settings.version = importlib.metadata.version("lnbits")
# printing environment variable for debugging
@@ -388,8 +427,10 @@ if not settings.lnbits_admin_ui:
logger.debug(f"{key}: {value}")
wallets_module = importlib.import_module("lnbits.wallets")
FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
def get_wallet_class():
"""
Backwards compatibility
"""
from lnbits.wallets import get_wallet_class
# initialize as fake wallet
WALLET = FAKE_WALLET
return get_wallet_class()
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -9
View File
File diff suppressed because one or more lines are too long
+12 -20
View File
@@ -325,7 +325,7 @@ body[data-theme='autumn'].body--dark {
[data-theme=flamingo] .q-drawer--dark,
body[data-theme=flamingo].body--dark,
[data-theme=flamingo] .q-menu--dark {
background: #803a45 !important;
background: #2f032f !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
@@ -336,38 +336,38 @@ body[data-theme='flamingo'].body--dark {
*/
[data-theme=flamingo] .q-card--dark,
[data-theme=flamingo] .q-stepper--dark {
background: #ec7599 !important;
background: #bc23bc !important;
}
[data-theme=flamingo] .bg-primary {
background: #d11d53 !important;
background: #ff00ff !important;
}
[data-theme=flamingo] .text-primary {
color: #d11d53 !important;
color: #ff00ff !important;
}
[data-theme=flamingo] .bg-secondary {
background: #db3e6d !important;
background: #fda3fd !important;
}
[data-theme=flamingo] .text-secondary {
color: #db3e6d !important;
color: #fda3fd !important;
}
[data-theme=flamingo] .bg-dark {
background: #803a45 !important;
background: #2f032f !important;
}
[data-theme=flamingo] .text-dark {
color: #803a45 !important;
color: #2f032f !important;
}
[data-theme=flamingo] .bg-info {
background: #ec7599 !important;
background: #bc23bc !important;
}
[data-theme=flamingo] .text-info {
color: #ec7599 !important;
color: #bc23bc !important;
}
[data-theme=flamingo] .bg-marginal-bg {
background: #803a45 !important;
background: #311231 !important;
}
[data-theme=flamingo] .text-marginal-bg {
color: #803a45 !important;
color: #311231 !important;
}
[data-theme=flamingo] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
@@ -454,14 +454,6 @@ body[data-theme='monochrome'].body--dark {
background: #0f47af !important;
}
[data-theme=flamingo] .q-drawer--dark {
background: #e75480 !important;
}
[data-theme=flamingo] .q-header {
background: #e75480 !important;
}
[v-cloak] {
display: none;
}
+2 -1
View File
@@ -37,7 +37,6 @@ window.localisation.br = {
toggle_darkmode: 'Alternar modo escuro',
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
api_docs: 'Documentação da API',
commit_version: 'Versão de commit',
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta',
@@ -63,6 +62,8 @@ window.localisation.br = {
invoice_waiting: 'Fatura aguardando pagamento',
payment_received: 'Pagamento Recebido',
payment_sent: 'Pagamento Enviado',
receive: 'receber',
send: 'enviar',
outgoing_payment_pending: 'Pagamento pendente de saída',
drain_funds: 'Drenar Fundos',
drain_funds_desc:
+122
View File
@@ -0,0 +1,122 @@
window.localisation.cn = {
confirm: '确定',
server: '服务器',
theme: '主题',
funding: '资金',
users: '用户',
restart: '重新启动服务器',
save: '保存',
save_tooltip: '保存更改',
topup: '充值',
topup_wallet: '给钱包充值',
topup_hint: '使用钱包ID为任何钱包充值',
restart_tooltip: '重新启动服务器以使更改生效',
add_funds_tooltip: '为钱包添加资金',
reset_defaults: '重置为默认设置',
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字',
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description:
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
export_to_phone: '通过二维码导出到手机',
export_to_phone_desc:
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
wallets: '钱包',
add_wallet: '添加新钱包',
delete_wallet: '删除钱包',
delete_wallet_desc: '整个钱包将被删除,资金将无法恢复',
rename_wallet: '重命名钱包',
update_name: '更新名称',
press_to_claim: '点击领取比特币',
donate: '捐献',
view_github: '在GitHub上查看',
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
toggle_darkmode: '切换暗黑模式',
view_swagger_docs: '查看 LNbits Swagger API 文档',
api_docs: 'API文档',
lnbits_version: 'LNbits版本',
runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户',
credit_label: '%{denomination} 充值',
paste_request: '粘贴请求',
create_invoice: '创建发票',
camera_tooltip: '用相机扫描发票/二维码',
export_csv: '导出为CSV',
transactions: '交易记录',
chart_tooltip: '显示图表',
pending: '待处理',
copy_invoice: '复制发票',
close: '关闭',
cancel: '取消',
scan: '扫描',
read: '读取',
pay: '付款',
memo: '备注',
date: '日期',
processing_payment: '正在处理支付...',
not_enough_funds: '资金不足!',
search_by_tag_memo_amount: '按标签、备注、金额搜索',
invoice_waiting: '待支付的发票',
payment_received: '收到付款',
payment_sent: '付款已发送',
receive: '收款',
send: '付款',
outgoing_payment_pending: '付款正在等待处理',
drain_funds: '清空资金',
drain_funds_desc:
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
i_understand: '我明白',
copy_wallet_url: '复制钱包URL',
disclaimer_dialog:
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
no_transactions: '尚未进行任何交易',
manage_server: '管理服务器',
extensions: '扩展程序',
no_extensions: '你没有安装任何扩展程序 :(',
created: '已创建',
search_extensions: '搜索扩展程序',
warning: '警告',
manage: '管理',
repository: '代码库',
confirm_continue: '你确定要继续吗?',
manage_extension_details: '安装/卸载扩展程序',
install: '安装',
uninstall: '卸载',
drop_db: '删除数据',
open: '打开',
enable: '启用',
enable_extension_details: '为当前用户启用扩展程序',
disable: '禁用',
installed: '已安装',
activated: '已激活',
deactivated: '已停用',
release_notes: '发布说明',
activate_extension_details: '对用户开放或禁用扩展程序',
featured: '精选',
all: '全部',
only_admins_can_install: '(只有管理员账户可以安装扩展)',
admin_only: '仅限管理员',
new_version: '新版本',
extension_depends_on: '依赖于:',
extension_rating_soon: '即将推出评分',
extension_installed_version: '已安装的版本',
extension_uninstall_warning: '您即将对所有用户删除该扩展程序。',
uninstall_confirm: '是的,卸载',
extension_db_drop_info: '该扩展程序的所有数据将被永久删除。此操作无法撤销!',
extension_db_drop_warning:
'您即将删除该扩展的所有数据。请继续输入扩展程序名称以确认操作:',
extension_min_lnbits_version: '此版本要求最低的 LNbits 版本为',
payment_hash: '付款哈希',
fee: '费',
amount: '金额',
unit: '单位',
description: '详情',
expiry: '过期时间',
webhook: 'Webhook',
payment_proof: '付款证明'
}
+2 -1
View File
@@ -40,7 +40,6 @@ window.localisation.de = {
toggle_darkmode: 'Auf Dark Mode umschalten',
view_swagger_docs: 'LNbits Swagger API-Dokumente',
api_docs: 'API docs',
commit_version: 'Commit Version',
runs_on: 'Läuft auf',
credit_hint: 'Klicke Enter, um das Konto zu belasten',
credit_label: '%{denomination} zu belasten',
@@ -66,6 +65,8 @@ window.localisation.de = {
invoice_waiting: 'Rechnung wartend auf Zahlung',
payment_received: 'Zahlung erhalten',
payment_sent: 'Zahlung gesendet',
receive: 'erhalten',
send: 'schicken',
outgoing_payment_pending: 'Ausgehende Zahlung wartend',
drain_funds: 'Sats abziehen',
drain_funds_desc:
+81 -2
View File
@@ -1,8 +1,27 @@
window.localisation.en = {
confirm: 'Yes',
server: 'Server',
theme: 'Theme',
funding: 'Funding',
users: 'Users',
apps: 'Apps',
channels: 'Channels',
transactions: 'Transactions',
dashboard: 'Dashboard',
manage_node: 'Manage Node',
total_capacity: 'Total Capacity',
avg_channel_size: 'Avg. Channel Size',
biggest_channel_size: 'Biggest Channel Size',
smallest_channel_size: 'Smallest Channel Size',
number_of_channels: 'Number of Channels',
active_channels: 'Active Channels',
connect_peer: 'Connect Peer',
connect: 'Connect',
open_channel: 'Open Channel',
open: 'Open',
close_channel: 'Close Channel',
close: 'Close',
restart: 'Restart server',
save: 'Save',
save_tooltip: 'Save your changes',
@@ -28,6 +47,9 @@ window.localisation.en = {
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
rename_wallet: 'Rename wallet',
update_name: 'Update name',
fiat_tracking: 'Fiat tracking',
currency: 'Currency',
update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin',
donate: 'Donate',
view_github: 'View on GitHub',
@@ -36,11 +58,12 @@ window.localisation.en = {
toggle_darkmode: 'Toggle Dark Mode',
view_swagger_docs: 'View LNbits Swagger API docs',
api_docs: 'Api docs',
commit_version: 'Commit version',
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account',
credit_label: '%{denomination} to credit',
paste: 'Paste',
paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request',
create_invoice: 'Create Invoice',
camera_tooltip: 'Use camera to scan an invoice/QR',
@@ -62,6 +85,8 @@ window.localisation.en = {
invoice_waiting: 'Invoice waiting to be paid',
payment_received: 'Payment Received',
payment_sent: 'Payment Sent',
receive: 'receive',
send: 'send',
outgoing_payment_pending: 'Outgoing payment pending',
drain_funds: 'Drain Funds',
drain_funds_desc:
@@ -84,6 +109,7 @@ window.localisation.en = {
manage_extension_details: 'Install/uninstall extension',
install: 'Install',
uninstall: 'Uninstall',
drop_db: 'Remove Data',
open: 'Open',
enable: 'Enable',
enable_extension_details: 'Enable extension for current user',
@@ -96,6 +122,7 @@ window.localisation.en = {
featured: 'Featured',
all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)',
admin_only: 'Admin Only',
new_version: 'New Version',
extension_depends_on: 'Depends on:',
extension_rating_soon: 'Ratings coming soon',
@@ -103,6 +130,11 @@ window.localisation.en = {
extension_uninstall_warning:
'You are about to remove the extension for all users.',
uninstall_confirm: 'Yes, Uninstall',
extension_db_drop_info:
'All data for the extension will be permanently deleted. There is no way to undo this operation!',
extension_db_drop_warning:
'You are about to remove all data for the extension. Please type the extension name to continue:',
extension_min_lnbits_version: 'This release requires at least LNbits version',
payment_hash: 'Payment Hash',
@@ -112,5 +144,52 @@ window.localisation.en = {
description: 'Description',
expiry: 'Expiry',
webhook: 'Webhook',
payment_proof: 'Payment Proof'
payment_proof: 'Payment Proof',
update_available: 'Update %{version} available!',
latest_update: 'You are on the latest version %{version}.',
notifications: 'Notifications',
no_notifications: 'No notifications',
notifications_disabled: 'LNbits status notifications are disabled.',
enable_notifications: 'Enable Notifications',
enable_notifications_desc:
'If enabled it will fetch the latest LNbits Status updates, like security incidents and updates.',
enable_killswitch: 'Enable Killswitch',
enable_killswitch_desc:
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc:
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).',
enable_watchdog: 'Enable Watchdog',
enable_watchdog_desc:
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
watchdog_interval: 'Watchdog Interval',
watchdog_interval_desc:
'How often the background task should check for a killswitch signal in the watchdog delta [node_balance - lnbits_balance] (in minutes).',
watchdog_delta: 'Watchdog Delta',
watchdog_delta_desc:
'Limit before killswitch changes funding source to VoidWallet [lnbits_balance - node_balance > delta]',
status: 'Status',
notification_source: 'Notification Source',
notification_source_label:
'Source URL (only use the official LNbits status source, and sources you can trust)',
more: 'more',
releases: 'Releases',
killswitch: 'Killswitch',
watchdog: 'Watchdog',
server_logs: 'Server Logs',
ip_blocker: 'IP Blocker',
security: 'Security',
security_tools: 'Security tools',
block_access_hint: 'Block access by IP',
allow_access_hint: 'Allow access by IP (will override blocked IPs)',
enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter',
number_of_requests: 'Number of requests',
time_unit: 'Time unit',
minute: 'minute',
second: 'second',
hour: 'hour',
disable_server_log: 'Disable Server Log',
enable_server_log: 'Enable Server Log',
coming_soon: 'Feature coming soon'
}
+2 -1
View File
@@ -38,7 +38,6 @@ window.localisation.es = {
toggle_darkmode: 'Cambiar modo oscuro',
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
api_docs: 'Documentos de API',
commit_version: 'Versión de compromiso',
runs_on: 'Corre en',
credit_hint: 'Presione Enter para cargar la cuenta',
credit_label: 'Cargar %{denomination}',
@@ -63,6 +62,8 @@ window.localisation.es = {
invoice_waiting: 'Factura esperando pago',
payment_received: 'Pago recibido',
payment_sent: 'Pago enviado',
receive: 'recibir',
send: 'enviar',
outgoing_payment_pending: 'Pago saliente pendiente',
drain_funds: 'Drenar fondos',
drain_funds_desc:
+2 -1
View File
@@ -41,7 +41,6 @@ window.localisation.fr = {
toggle_darkmode: 'Basculer le mode sombre',
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
api_docs: "Documents de l'API",
commit_version: 'Version de commit',
lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur',
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
@@ -67,6 +66,8 @@ window.localisation.fr = {
invoice_waiting: 'Facture en attente de paiement',
payment_received: 'Paiement reçu',
payment_sent: 'Paiement envoyé',
receive: 'recevoir',
send: 'envoyer',
outgoing_payment_pending: 'Paiement sortant en attente',
drain_funds: 'Vider les fonds',
drain_funds_desc:
+2 -1
View File
@@ -38,7 +38,6 @@ window.localisation.it = {
toggle_darkmode: 'Attiva la modalità notturna',
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
api_docs: 'Documenti API',
commit_version: 'Commit version',
lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su',
credit_hint: 'Premere Invio per accreditare i fondi',
@@ -64,6 +63,8 @@ window.localisation.it = {
invoice_waiting: 'Fattura in attesa di pagamento',
payment_received: 'Pagamento ricevuto',
payment_sent: 'Pagamento inviato',
receive: 'ricevere',
send: 'inviare',
outgoing_payment_pending: 'Pagamento in uscita in attesa',
drain_funds: 'Fondi di drenaggio',
drain_funds_desc:

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