Compare commits

...
130 Commits
Author SHA1 Message Date
dni ⚡andGitHub 033a57cad4 chore: update to 0.12.3 (#2344) 2024-03-22 13:03:00 +01:00
dni ⚡andGitHub 299228b7b5 feat: reduce initial requests on wallet page (#2335)
* feat: reduce initial request on wallet page
- refactor allowed_currencies into function to use in generic and api.
- remove currencies request in frontend move it to generic
- dont request balance on first payments fetch
2024-03-22 12:59:49 +01:00
dni ⚡andGitHub 5b022e2ef3 feat: make route hints configurable for lndrest (#2304)
* feat: make route hints configurable for lndrest
boolean if route hints for private channels will be included from `LndRest` inside bolt11 invoice.
2024-03-22 12:45:39 +01:00
Pavol RusnakandGitHub c8818f5774 remove CACHE_VERSION from service-worker.js (#2342)
use server_startup_time as the version
(the same trick we use for static files)
2024-03-22 12:33:42 +01:00
dni ⚡andGitHub 1398857688 refactor: move registering of tasks into app.py (#2290)
* refactor: move registering of tasks into `app.py`

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

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

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

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

---------

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

* fix: invoice status

* fix: check pending status from the payment details

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

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

is not raised even failed webhook would be marked is sent

* add warn log

* fix error

* improve exceptions

* fixup!

* ConnectError is already captured by RequestError

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

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

---------

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

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

* chore: add reference to jmter job

* chore: add jmeter job

* chore: touch tests

* chorE: disable other workflows

* chore: do not set `LNBITS_ADMIN_UI: true`

* chore: extra log file

* chore: rollback

* chore: testing stuff

* feat: top-up wallet

* chore: clean-up usr

* chore: clean-up user query param

* chore: remove hardcoded usr

* chore: clean-up

* chore: clean-up

* chore: different improvements

* chore: clean-up

* chore: remove hardcoded values

* refactor: extract `init-server` fragment

* chore: move fragment

* feat: use test frgment

* chore: clean-up hardcoded values

* chore: clean-up

* fix: get lnurl working

* chore: re-arange elements

* chore: re-arange elements

* fix: add `lnurlp, withdraw` extensions

* chore: rename file

* fix: login with user id

* refactor: extract init account

* fix: tips wallet

* fix: assertion

* refactor: use fragments; group loops

* chore: re-enable the other CI jobs

* fix: separate wallet for receive

* feat: check receive wallet balance

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

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

more linting :)

* fix linting issues in tests and tools

* fixup!

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

* feat: only expose relevant fields

* refactor: extract `BaseWallet`

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

add zbd as funding source to settings and js

fix statuses

cast str into int for status method

fix outbound payment hash issue

restore create_invoice

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

* feat: add `pay_link` property

* feat: basic install using internal wallet for payment

* fix: pop-up issues

* chore: refactor

* feat: detect paid extensions

* fix: payment check

* feat: small stuff

* feat: show external invoice

* fix: regression for extension install

* feat: store previos successful payments

* refactor: simplify, almost works

* chore: gugu gaga

* fix: pay and install

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

* chore: code clean-up

* feat: basic websocker listener

* feat: use websocket to watch for invoice payment

* feat: remember hanging invoices

* refactor: extract `localStorage` methods

* chore: code format

* chore: code clean-up after test

* feat: remember previous payment_hashes

* chore: code format

* refactor: rename `ExtensionPaymentInfo` to `ReleasePaymentInfo`

* refactor: method rename

* fix: release version matters now

* chore: code format

* refactor: method rename

* refactor: extract method `_restore_payment_info`

* refactor: extract method

* chore: rollback `CACHE_VERSION`

* chore: code format

* feat: i18n

* chore: update bundle

* refactor: public method name

* chore: code format

* fix: websocket connection

* Update installation.md (#2259)

* Update installation.md (#2260)

* fix: try to fix `openapi` error

* chore: bundle

* chore:bundle

---------

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

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

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

* fix: `make check`

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


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

closes #2277

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

* regenerate localization with i18n-ai-tool

* chore: make bundle

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-02-20 13:32:49 +02:00
dni ⚡andGitHub 7b559991c7 fix: docker image invoice listener were not stopping (#2278)
because it could not resolve localhost
2024-02-16 15:24:50 +02:00
dni ⚡andGitHub decd4cdf0a chore: remove listener dict (#2279) 2024-02-16 14:47:52 +02:00
dni ⚡andGitHub dc7a6551c5 chore: deprecate get_current_extension_name (#2280) 2024-02-16 13:48:50 +02:00
526467747e Add Keycloak SSO (#2272)
* feat: add `keycloak` SSO

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-02-14 09:23:37 +01:00
Vlad StanandGitHub b8d295a5b7 refactor: generalize SSO auth (#2263)
* refactor: first extraction of providers
* refactor: remove unused property
* refactor: extract `_find_auth_provider_class`
* fix: return type
* feat: prepare for `keycloak`
2024-02-14 07:57:50 +01:00
jackstar12andGitHub 293b6267be fix: correct invoice expiry for lndrest (#2271) 2024-02-13 15:14:09 +02:00
Vlad StanandGitHub 20e70854a1 fix: add installed extensions to the ext list if missing (#2261) 2024-02-12 15:33:28 +01:00
dni ⚡andGitHub 11173e6460 fix: github release missing checkout (#2264)
actions/checkout was missing so it could not release
2024-02-12 13:00:03 +01:00
dni ⚡andGitHub 069a7922fc chore: update to 0.12.1 (#2262) 2024-02-12 12:43:20 +01:00
98338ffb00 Improves payment reactions (#2251)
* updated to select

* updating

* working

* bundle

* updated to select

* updating

* working

* bundle

* Make room for everything apart from sending

* Update lnbits/static/js/base.js

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

* prettier

* bundled

* fundle

* changed to window[]

* vlads suggestion

* added stars

* vlads suggestion

* fundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-02-12 10:48:07 +00:00
Pavol Rusnak 62dec118af chore: make bundle 2024-02-09 12:36:07 -03:00
tlindiandPavol Rusnak 84b406534d Update fi.js 2024-02-09 12:36:07 -03:00
d9d2d59b73 Wallet limits: max balance, daily max withdraw, transactions per sec (#2223)
Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-02-09 17:25:53 +02:00
20d4b954c0 fix: custom site desc (#2254)
* custom site desc

* fiddled formatting

---------

Co-authored-by: arcbtc <ben@arc.wales>
2024-02-09 15:08:02 +00:00
dni ⚡andGitHub 5edb845d8f feat: add setting lnbits_allow_new_accounts to admin ui (#2252)
added to users tab of admin ui
2024-02-09 14:54:30 +02:00
cfe2b43d2a Improved env layout (#2226)
* Improved env layout

* Update .env.example

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-02-09 14:34:10 +02:00
Vlad StanandGitHub a0ac41a9ce fix: use get_dbversions (#2255) 2024-02-09 14:10:51 +02:00
dni ⚡andGitHub 194a527736 fix: regtest use new docker hub address (#2248)
* fix: regtest use new docker hub address
2024-02-09 08:46:33 +01:00
dni ⚡andGitHub 6f135c0696 feat: add monitor admin endpoint for listeners (#2242)
this can be useful when debugging invoice listeners. in the future there coud be some monitor dashboard on the admin ui.
2024-02-09 08:08:37 +01:00
dni ⚡andGitHub 9a767b627f chore: dead code perform_balance_check (#2244)
this code is not used anywhere on core nor on extensions
2024-02-08 15:01:23 +01:00
c1c4eda69d Adds optional confetti to incoming payments in wallet (#2231)
* initial

* Working with toggle

* Prettier and bundle

* removed console.log

* bundle

* fix: i18n

* refactor: simplify logic

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-02-08 13:34:03 +00:00
dni ⚡andGitHub 6c46867698 chore: update readme to use new docker alias (#2246)
`docker pull lnbits/lnbits`
2024-02-08 13:24:55 +00:00
dni ⚡andGitHub a73eb569ae bug: docker upload workflow (#2245)
something changed in the docker-build-push action.

added `workflow_dispatch` to be able to manually run those docker releases via github
2024-02-08 11:30:38 +01:00
ArcandGitHub f07b40735e Fixes annoying qr popup on parsing LNURL withdraw in wallet (#2230) 2024-02-07 12:08:49 +00:00
Pavol Rusnak 94f74d3f56 chore: make bundle 2024-02-06 14:45:13 -03:00
tlindiandPavol Rusnak d9a5e63e51 fixes to Finnish translation 2024-02-06 14:45:13 -03:00
dni ⚡andGitHub c8e5c23ac2 chore: update to latest fastapi (#2240)
security alert https://github.com/lnbits/lnbits/security/dependabot/46
2024-02-06 11:47:36 -03:00
Vlad StanandGitHub 59968e38d1 fix: check for username before allowing password change (#2239) 2024-02-06 11:48:13 +02:00
Pavol RusnakandGitHub 4f45781319 alby: fix balance reporting and error handling + opennode: fix error handling (#2221)
* opennode: fix error handling

* alby: fix balance reporting and error handling
2024-02-06 10:26:50 +02:00
Vlad StanandGitHub 22ea83e843 Fix extension navigation (#2236)
* fix: avoid `path` clash with other routes (`/withdraw` in this case)

* fix: refresh user state after extension action

* chore: fix `mypy`
2024-02-05 11:39:35 +00:00
dni ⚡andGitHub 55fed49be1 feat: use github release draft (#2237)
create a draft release on tagging
2024-02-05 12:01:11 +01:00
Pavol Rusnak 311d9a668b chore: make bundle 2024-02-02 17:25:13 -03:00
tlindiandPavol Rusnak 165f5093f4 add Finnish translation 2024-02-02 17:25:13 -03:00
43d92ddb51 [FEAT] release / docker ci pipeline (#1907)
* [FEAT] docker ci pipeline

* add release workflow

* depend on release

* move tag

* remove push to main

* fixup release workflow

* add latest tag on dockerhub

* cleanup

* Update .github/workflows/docker.yml

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

* use latest versions of actions

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-01-30 13:28:34 +00:00
dni ⚡andGitHub 10944bf100 refactor: add prepareFilterQuery to utils (#2219)
* refactor: add `prepareFilterQuery` to utils
factored out the preparation of the url params for a paginated table and its requests.
usermanager will also use that.
2024-01-30 08:05:39 +01:00
dni ⚡andGitHub ce9b2c3b77 refactor: move admin ui tabs around (#2218)
move authentication into security in preparation of user manager pr where users tab will be removed.
move service fee into server tab, i think its more fitting there.
2024-01-30 07:49:19 +01:00
dni ⚡andGitHub 4c102a08e8 refactor: topup wallet + into component (#2217)
* refactor: topup wallet `+` into component
this will also be used in the usermanager
* bundle
2024-01-30 07:48:59 +01:00
1d2b939e06 feat: cleanup-wallets cli command (#2176)
* feat: `cleanup-wallet` cli command
`lnbits-cli db cleanup-wallets` removes all wallets that never had an transaction.
this helps against spammers creating random wallets, eventually slowing down the db.
* add commands
* add to example env
* db_versions was used in app
* add delta as cli argument
* use days unit
* simplify cli argument name (cleanup_wallet_days -> days)!

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-01-30 07:47:15 +01:00
e1bb2113ed feat: install wizard on first launch (#1977)
* Login form loading

* add first install middleware and settings

* updates

* Login form loading

* add first install middleware and settings

* updates

* only set first install when superuser is created

* refactor first install

* only show if first install

* cleanup

* set password

* update calls

* login superuser on first install

* fix

* fixup!

* fixup!

* fixup!

* fixup!

* fixup!

* last fixup!

* fix mypy and prettier CI errors

* disable first install

* add random super user

* set first install after startup

* remove user id from form

* Update lnbits/core/views/auth_api.py

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

* Update lnbits/core/views/auth_api.py

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

* Update lnbits/middleware.py

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

* addressing Vlad's comments

* remove super user

* move to transient settings

* fix: show `first_install` page even after a server restart

* fix: do not add `user_id` in the auth token

* fix: `make check` errors

* fix: `username` is not optional for `UpdateSuperuserPassword`

* feat: nicer error message

---------

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-01-25 13:33:40 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b71113700a chore(deps): bump follow-redirects from 1.15.2 to 1.15.4 (#2203)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-23 08:17:12 +01:00
mrbppandGitHub ebfcc46c9e feat: improve PWA configuration (#2214)
---
PWA Errors:
---
- fix logo size
- add shortcut icon
- add maskable icons
- add desktop and phone screenshots (wide and narrow)
2024-01-23 08:14:37 +01:00
Pavol Rusnak c047ae6808 chore: make bundle 2024-01-22 10:23:14 -06:00
dni ⚡andPavol Rusnak 023a1a088e feat: add currency amount to lnurl/lnaddress payments
closes #2135
2024-01-22 10:23:14 -06:00
Pavol Rusnak 8eabf53642 introduce Wallet.normalize_endpoint to remove code duplication 2024-01-22 10:06:22 -06:00
Pavol Rusnak 824a8fa7c8 refactor __init__ functions of Wallets (funding sources)
- better error messages when a conf variable is not provided
- unify approaches among different source files
2024-01-22 10:06:22 -06:00
Pavol RusnakandGitHub a3b7c76523 sort CSV export of payments (#2208) 2024-01-22 08:03:31 -06:00
Vlad StanandGitHub 26ca8c71d7 Deactivate all extensions flag (#2206)
* feat: allow all extension deactivation

* doc: updated comment

* fix: make sure `register_routes` executes after installed extensions are checked

* chore: code format

* fix: do not run migration on deactivated extensions

* fix: make sure the deactivated extension list is loaded in time

* feat: register extension routes if extension never loaded before

* fix: move `load_disabled_extension_list`

* doc: disable by default
2024-01-22 12:18:12 +02:00
Vlad StanandGitHub 0d2447faf3 refactor: extract encrypt_internal_message and decrypt_internal_message (#2210) 2024-01-16 15:33:11 +00:00
Vlad StanandGitHub 4cea06c5a5 Move UI configs to profile (#2201)
* feat: add tabs

* feat: move buttons to user profile

* feat: i18n

* refactor: move ui methods to `account` component
2024-01-15 11:51:34 +02:00
Vlad StanandGitHub 031ce14857 refactor: extract AESCipher to crypto.py (#2202) 2024-01-15 11:51:15 +02:00
bd143f5c14 added directions for cache (#2197)
---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-01-10 20:09:08 -05:00
dni ⚡andGitHub 4e9c992a91 revert: migration (#2198)
took to long on legend to run, now taking current date as default
2024-01-09 11:34:53 +00:00
fusion44andPavol Rusnak d6ac3d2672 fix: remove custom devshells from the flake
The definition of the dev shell in the flake file had several
issues when opening a development shell. However, this is
unnecessary as mkPoetryApplication already creates a
default working dev shell.
2023-12-28 13:25:34 +01:00
dni ⚡andPavol Rusnak 1933d896cb chore: update to version 0.12.0 2023-12-23 12:15:03 +01:00
Pavol Rusnak 168a3bcddb chore: make bundle 2023-12-22 20:10:30 +01:00
Pavol Rusnak adc342ab9a reword API docs to API keys and API docs in the wallet accordeon 2023-12-22 20:10:30 +01:00
Pavol Rusnak bbf3cd96f1 move API docs as last item of wallet actions 2023-12-22 20:10:30 +01:00
e8c9304519 Simplified mobile wallet (#2175)
---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2023-12-22 17:32:01 +01:00
815c3e61e4 feat: add created_at and updated_at to wallets and accounts (#2177)
* feat: add `created_at` and `updated_at` to wallets and accounts

the title says it all :)

* fixup!

* nitpicks :)

* fixup!

* sqlite fix

* sqlite compat

* fixup!

* mypy

* revert db py

* motorinas suggestions

* int(time()) proper default values in migration

* uncomment migration

* use now = int(time()) idiom to make code more readable

also this fixes the issue where time() is called multiple times
providing different return values for multiple invocations

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2023-12-21 12:37:56 +00:00
Vlad StanandGitHub 4e55ea18e5 feat: remember last active wallet (#2189)
* feat: store last active wallet as a cookie

* fix: param check
2023-12-21 14:19:58 +02:00
Vlad StanandGitHub 81b70acf5f fix: position of method invocation (#2188)
* fix: move call to `checkUsrInUrl` outside the `if` branch

* chore: bundle update
2023-12-20 13:16:35 +00:00
callebtcandGitHub 165d7499ea LndResWallet: catch http errors (#2133) 2023-12-19 15:33:18 +00:00
Kalle RosenbaumandGitHub abbcfbeb6a cln: allow unpaid amountless invoices (#2138) 2023-12-19 12:12:52 +01:00
Pavol RusnakandGitHub bff44b7097 update nix ci action to use cachix to cache results (#2170) 2023-12-19 11:54:14 +01:00
Pavol RusnakandGitHub 95f6ae2bb6 chore: new translations (#2183)
* new translations
* chore: make bundle
2023-12-19 11:22:37 +01:00
Pavol RusnakandGitHub a15af87f9b fix make checkbundle which should not bump the serviceworker version (#2184) 2023-12-19 11:22:05 +01:00
97f8a4ab38 fix: urls for includes on print.html (#2185)
* fix the url for js libs
* fix the url for css

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2023-12-18 09:30:38 +01:00
HaosGamesandGitHub 7b9d370452 fix nixos module getExe warning (#2182) 2023-12-15 07:43:43 +01:00
Vlad StanandGitHub db72953310 Login polish (#2180)
* feat: set `samesite="none", secure=True` for `is_lnbits_user_authorized` (required in iframe)
* feat: add icons for User ID actions
* feat: set `samesite="none", secure=True
2023-12-14 15:07:22 +01:00
Vlad StanandGitHub bb918a8523 Login improve UI config (#2171)
* feat: show auth configs on the admin UI
* fix: do not access settings on load
* fix: redirect for click on item (not only on text)
* fix: remove `Display Name`
* fix: do not show `Verify email with` if no auth option is available
* feat: show warning before logout
* feat: i18n of account page
* fix: show account icon for user ID login
* fix: always check `isUserAuthorized`
* fix: update the `disclaimer_dialog` message
* feat: hide user ID by default
* fix: redirect from login page when user authorized
* feat: update logout message
* fix: do not translate company names
2023-12-14 11:34:23 +01:00
Tiago VasconcelosandGitHub 24b02cc656 fix not showing account for existing users (#2173) 2023-12-12 17:25:35 +00:00
Pavol RusnakandGitHub 9877ca602f use _blank for external links on main (#2172) 2023-12-12 15:08:30 +01:00
c9093715b7 [FEAT] Auth, Login, OAuth, create account with username and password #1653 (#2092)
no more superuser url!
delete cookie on logout
add usr login feature
fix node management
* Cleaned up login form
* CreateUser
* information leak
* cleaner parsing usr from url
* rename decorators
* login secret
* fix: add back `superuser` command
* chore: remove `fastapi_login`
* fix: extract `token` from cookie
* chore: prepare to extract user
* feat: check user
* chore: code clean-up
* feat: happy flow working
* fix: usr only login
* fix: user already logged in
* feat: check user in URL
* fix: verify password at DB level
* fix: do not show `Login` controls if user already logged in
* fix: separate login endpoints
* fix: remove `usr` param
* chore: update error message
* refactor: register method
* feat: logout
* chore: move comments
* fix: remove user auth check from API
* fix: user check unnecessary
* fix: redirect after logout
* chore: remove garbage files
* refactor: simplify constructor call
* fix: hide user icon if not authorized
* refactor: rename auth env vars
* chore: code clean-up
* fix: add types for `python-jose`
* fix: add types for `passlib`
* fix: return type
* feat: set default value for `auth_secret_key` to hash of super user
* fix: default value
* feat: rework login page
* feat: ui polishing
* feat: google auth
* feat: add google auth
* chore: remove `authlib` dependency
* refactor: extract `_handle_sso_login` method
* refactor: convert methods to `properties`
* refactor: rename: `user_api` to `auth_api`
* feat: store user info from SSO
* chore: re-arange the buttons
* feat: conditional rendering of login options
* feat: correctly render buttons
* fix: re-add `Claim Bitcoin` from the main page
* fix: create wallet must send new user
* fix:  no `username-password` auth method
* refactor: rename auth method
* fix: do not force API level UUID4 validation
* feat: add validation for username
* feat: add account page
* feat: update account
* feat: add `has_password` for user
* fix: email not editable
* feat: validate email for existing account
* fix: register check
* feat: reset password
* chore: code clean-up
* feat: handle token expired
* fix: only redirect if `text/html`
* refactor: remove `OAuth2PasswordRequestForm`
* chore: remove `python-multipart` dependency
* fix: handle no headers for exception
* feat: add back button on error screen
* feat: show user profile image
* fix: check account creation permissions
* fix: auth for internal api call
* chore: add some docs
* chore: code clean-up
* fix: rebase stuff
* fix: default value types
* refactor: customize error messages
* fix: move types libs to dev dependencies
* doc: specify the `Authorization callback URL`
* fix: pass missing superuser id in node ui test
* fix: keep usr param on wallet redirect
removing usr param causes an issue if the browser doesnt yet have an access token.
* fix: do not redirect if `wal` query param not present
* fix: add nativeBuildInputs and buildInputs overrides to flake.nix
* bump fastapi-sso to 0.9.0 which fixes some security issues
* refactor: move the `lnbits_admin_extensions` to decorators
* chore: bring package config from `dev`
* chore: re-add dependencies
* chore: re-add cev dependencies
* chore: re-add mypy ignores
* feat: i18n
* refactor: move admin ext check to decorator (fix after rebase)
* fix: label mapping
* fix: re-fetch user after first wallet was created
* fix: unlikely case that `user` is not found
* refactor translations (move '*' to code)
* reorganize deps in pyproject.toml, add comment
* update flake.lock and simplify flake.nix after upstreaming
overrides for fastapi-sso, types-passlib, types-pyasn1, types-python-jose
were upstreamed in https://github.com/nix-community/poetry2nix/pull/1463
* fix: more relaxed email verification (by @prusnak)
* fix: remove `\b` (boundaries) since we re using `fullmatch`
* chore: `make bundle`

---------

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Arc <ben@arc.wales>
Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2023-12-12 11:38:19 +01:00
145 changed files with 17278 additions and 1987 deletions
+66 -16
View File
@@ -1,11 +1,34 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/ #For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
########### Admin Settings ###########
######################################
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=127.0.0.1
PORT=5000
###################################### ######################################
########## Funding Source ############ ########## Funding Source ############
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet" LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -61,6 +84,10 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/ ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# OpenNodeWallet # OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY OPENNODE_KEY=OPENNODE_ADMIN_KEY
@@ -79,25 +106,34 @@ LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips LNTIPS_API_ENDPOINT=https://ln.tips
###################################### ######################################
########### Admin Settings ########### ####### Auth Configurations ##########
###################################### ######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available # Google OAuth Config
# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will # Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
# be stored in your database and you will be able to change them only through the Admin UI. GOOGLE_CLIENT_ID=""
# Disable this to make LNbits use this config file again. GOOGLE_CLIENT_SECRET=""
LNBITS_ADMIN_UI=false
# Change theme # GitHub OAuth Config
LNBITS_SITE_TITLE="LNbits" # Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
LNBITS_SITE_TAGLINE="free and open-source lightning wallet" GITHUB_CLIENT_ID=""
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" GITHUB_CLIENT_SECRET=""
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=127.0.0.1 # Keycloak OAuth Config
PORT=5000 # Make sure that the valid redirect URIs contain https://{domain}/api/v1/auth/keycloak/token
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy # uvicorn variable, uncomment to allow https behind a proxy
# FORWARDED_ALLOW_IPS="*" # FORWARDED_ALLOW_IPS="*"
@@ -118,6 +154,9 @@ LNBITS_ADMIN_USERS=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin" LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
# Disable account creation for new users # Disable account creation for new users
# LNBITS_ALLOW_NEW_ACCOUNTS=false # LNBITS_ALLOW_NEW_ACCOUNTS=false
@@ -172,6 +211,13 @@ LNBITS_RESERVE_FEE_MIN=2000
# value in percent # value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0 LNBITS_RESERVE_FEE_PERCENT=1.0
# limit the maximum balance for each wallet
# throw an error if the wallet attempts to create a new invoice
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
# Limit fiat currencies allowed to see in UI # Limit fiat currencies allowed to see in UI
# LNBITS_ALLOWED_CURRENCIES="EUR, USD" # LNBITS_ALLOWED_CURRENCIES="EUR, USD"
@@ -180,6 +226,7 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
###################################### ######################################
DEBUG=false DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/ # logging into LNBITS_DATA_FOLDER/logs/
@@ -188,3 +235,6 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file # https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB" LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
+10 -3
View File
@@ -38,9 +38,6 @@ jobs:
with: with:
make: openapi make: openapi
# docker:
# uses: ./.github/workflows/docker.yml
regtest: regtest:
needs: [ lint ] needs: [ lint ]
uses: ./.github/workflows/regtest.yml uses: ./.github/workflows/regtest.yml
@@ -51,3 +48,13 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }} backend-wallet-class: ${{ matrix.backend-wallet-class }}
jmeter:
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+46 -8
View File
@@ -1,20 +1,58 @@
name: docker name: docker
on: on:
workflow_dispatch:
inputs:
tag:
default: latest
type: string
workflow_call: workflow_call:
inputs:
tag:
default: latest
type: string
jobs: jobs:
docker: push_to_dockerhub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: false push: true
tags: lnbitsdocker/lnbits-legend:latest tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest platforms: linux/amd64,linux/arm64
cache-to: type=inline cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push latest tag
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+98
View File
@@ -0,0 +1,98 @@
name: JMeter Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.9"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
action_build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
cache: "pip"
- name: Set up Poetry ${{ inputs.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: create logs and reports dir
run: |
mkdir logs
mkdir reports
- name: install packages
run: poetry install
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
poetry run lnbits > logs/lnbits.log &
sleep 5
- name: install jmeter
run: |
java -version
wget https://downloads.apache.org//jmeter/binaries/apache-jmeter-5.6.2.zip
unzip apache-jmeter-5.6.2.zip
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/jmeter -v
- name: start mirror server
run: |
libs_dir=$GITHUB_WORKSPACE/apache-jmeter-5.6.2/lib/
echo "Fix bad Jmeter lib names. Lib dir: $libs_dir"
mv $libs_dir/slf4j-api-1.7.36.jar $libs_dir/slf4j-api-1.7.25.jar
mv $libs_dir/log4j-slf4j-impl-2.20.0.jar $libs_dir/log4j-slf4j-impl-2.11.0.jar
mv $libs_dir/log4j-api-2.20.0.jar $libs_dir/log4j-api-2.11.1.jar
mv $libs_dir/log4j-core-2.20.0.jar $libs_dir/log4j-core-2.11.1.jar
mv $libs_dir/log4j-1.2-api-2.20.0.jar $libs_dir/og4j-1.2-api-2.11.1.jar
echo "Starting the mirror server on dfault port 8081."
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/mirror-server &
- name: run jmx scripts
run: |
for file in $( ls $GITHUB_WORKSPACE/integration/*.jmx); do
echo "Running test with $file"
filename=$(basename "$file" ".jmx")
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/jmeter -n -t $file -l logs/$filename.log -e -o reports ;
error_count=$(cat jmeter.log | grep "summary =" | awk '{print $19}')
echo "Error count: $error_count"
if [[ "$error_count" == "0" ]]; then
echo "Test $filename OK."
rm -r reports/*
else
echo "Test $filename failed. Error count: $error_count."
exit 1
fi
done
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: jmeter-test-results
path: |
reports/
logs/
+17 -4
View File
@@ -1,15 +1,21 @@
name: LNbits CI / nix name: LNbits CI / nix
# - run : on main, dev, nix and cachix branches when relevant files change
# - cache : on main, dev and cachix branches when relevant files change
on: on:
push: push:
branches: branches:
- main - main
- dev - dev
- nix
- cachix
paths: paths:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'pyproject.toml' - 'pyproject.toml'
- 'poetry.lock' - 'poetry.lock'
- '.github/workflows/nix.yml'
pull_request: pull_request:
paths: paths:
- 'flake.nix' - 'flake.nix'
@@ -21,8 +27,15 @@ jobs:
nix: nix:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v23 - uses: cachix/install-nix-action@v24
with: with:
nix_path: nixpkgs=channel:nixos-unstable nix_path: nixpkgs=channel:nixos-23.11
- run: nix build - uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- run: nix build -L
- run: cachix push lnbits ./result
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
-68
View File
@@ -1,68 +0,0 @@
name: Build and push Docker image on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v3
- name: Import environment variables
id: import-env
shell: bash
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
--output "type=registry" ./
+3 -3
View File
@@ -33,8 +33,8 @@ jobs:
with: with:
context: . context: .
push: false push: false
tags: lnbitsdocker/lnbits-legend:latest tags: lnbits/lnbits:latest
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest cache-from: type=registry,ref=lnbits/lnbits:latest
cache-to: type=inline cache-to: type=inline
- name: Setup Regtest - name: Setup Regtest
@@ -51,7 +51,7 @@ jobs:
- name: Create fake admin - name: Create fake admin
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }} if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
- name: Run Tests - name: Run Tests
env: env:
+28
View File
@@ -0,0 +1,28 @@
name: release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
docker:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
+2 -2
View File
@@ -14,11 +14,11 @@ repos:
- id: mixed-line-ending - id: mixed-line-ending
- id: check-case-conflict - id: check-case-conflict
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.7.0 rev: 24.2.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283 rev: v0.3.2
hooks: hooks:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
+1 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.10-slim-bullseye
RUN apt-get clean RUN apt-get clean
RUN apt-get update RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
-3
View File
@@ -96,9 +96,6 @@ bundle:
npm run vendor_minify_css npm run vendor_minify_css
npm run vendor_bundle_js npm run vendor_bundle_js
npm run vendor_minify_js npm run vendor_minify_js
# increment serviceworker version
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
checkbundle: checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
+1
View File
@@ -29,6 +29,7 @@ LNbits can run on top of any Lightning funding source. It currently supports the
- LNbits - LNbits
- OpenNode - OpenNode
- Alby - Alby
- ZBD
- LightningTipBot - LightningTipBot
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source. See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
+20 -7
View File
@@ -65,17 +65,31 @@ poetry install --only main
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl -L https://nixos.org/nix/install) --daemon sh <(curl -L https://nixos.org/nix/install) --daemon
# Enable nix-command and flakes experimental features for nix:
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
# Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix use lnbits
# Clone and build LNbits # Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
nix build .#lnbits nix build
mkdir data
mkdir data
``` ```
#### Running the server #### Running the server
```sh ```sh
nix run
```
Ideally you would set the environment via the `.env` file,
but you can also set the env variables or pass command line arguments:
``` sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI. # .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
@@ -83,24 +97,23 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
## Option 3: Docker ## Option 3: Docker
use latest version from docker hub use latest version from docker hub
```sh ```sh
docker pull lnbitsdocker/lnbits-legend docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
build the image yourself build the image yourself
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
docker build -t lnbitsdocker/lnbits-legend . docker build -t lnbits/lnbits .
cp .env.example .env cp .env.example .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
## Option 4: Fly.io ## Option 4: Fly.io
+8
View File
@@ -87,6 +87,14 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/ - `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken - `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `ZBD_API_KEY`: ZBDApiKey
### Cliche Wallet ### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
Generated
+6 -6
View File
@@ -41,11 +41,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1701539137, "lastModified": 1702233072,
"narHash": "sha256-nVO/5QYpf1GwjvtpXhyxx5M3U/WN0MwBro4Lsk+9mL0=", "narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "933d7dc155096e7575d207be6fb7792bc9f34f6d", "rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1701861752, "lastModified": 1702334837,
"narHash": "sha256-QfrE05P66856b1SMan69NPhjc9e82VtLxBKg3yiQGW8=", "narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"owner": "nix-community", "owner": "nix-community",
"repo": "poetry2nix", "repo": "poetry2nix",
"rev": "9fc487b32a68473da4bf9573f85b388043c5ecda", "rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -8
View File
@@ -18,14 +18,6 @@
projectName = "lnbits"; projectName = "lnbits";
in in
{ {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
buildInputs = with pkgs; [
nodePackages.prettier
poetry-core
];
};
});
overlays = { overlays = {
default = final: prev: { default = final: prev: {
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName}; ${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
@@ -36,6 +28,7 @@
${projectName} = pkgs.poetry2nix.mkPoetryApplication { ${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.; projectDir = ./.;
meta.rev = self.dirtyRev or self.rev; meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: { overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
protobuf = prev.protobuf.override { preferWheel = true; }; protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; }; ruff = prev.ruff.override { preferWheel = true; };
+488
View File
@@ -0,0 +1,488 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
<hashTree>
<TestFragmentController guiclass="TestFragmentControllerGui" testclass="TestFragmentController" testname="Test Fragment" enabled="false"/>
<hashTree>
<GenericController guiclass="LogicControllerGui" testclass="GenericController" testname="Init Account" enabled="true"/>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get main page" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Create new account" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&quot;name&quot;:&quot;a1&quot;}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/account</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.${paidChargeCount}</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/${paidChargeCount}.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="Extract user and wallet" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())
vars.put(&quot;userId&quot;, resp.user || &quot;no-user-id&quot;);
vars.put(&quot;walletId&quot;, resp.id || &quot;no-wallet-id&quot;);
vars.put(&quot;inkey&quot;, resp.inkey || &apos;no-inkey&apos;);
vars.put(&quot;adminkey&quot;, resp.adminkey || &apos;no-adminkey&apos;);
</stringProp>
<stringProp name="scriptLanguage">javascript</stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Login with user id" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&quot;usr&quot;:&quot;${userId}&quot;}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/auth/usr</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.${paidChargeCount}</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/${paidChargeCount}.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Go to wallet page" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="wal" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.name">wal</stringProp>
<stringProp name="Argument.value">${walletId}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
<boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/wallet</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get currencies" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/currencies</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="X-Api-Key" elementType="Header">
<stringProp name="Header.name">X-Api-Key</stringProp>
<stringProp name="Header.value">undefined</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Currency List" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="1438956759">[&quot;AED&quot;,&quot;AFN&quot;,&quot;ALL&quot;,&quot;AMD&quot;,&quot;ANG&quot;,&quot;AOA&quot;,&quot;ARS&quot;,&quot;AUD&quot;,&quot;AWG&quot;,&quot;AZN&quot;,&quot;BAM&quot;,&quot;BBD&quot;,&quot;BDT&quot;,&quot;BGN&quot;,&quot;BHD&quot;,&quot;BIF&quot;,&quot;BMD&quot;,&quot;BND&quot;,&quot;BOB&quot;,&quot;BRL&quot;,&quot;BSD&quot;,&quot;BTN&quot;,&quot;BWP&quot;,&quot;BYN&quot;,&quot;BYR&quot;,&quot;BZD&quot;,&quot;CAD&quot;,&quot;CDF&quot;,&quot;CHF&quot;,&quot;CLF&quot;,&quot;CLP&quot;,&quot;CNH&quot;,&quot;CNY&quot;,&quot;COP&quot;,&quot;CRC&quot;,&quot;CUC&quot;,&quot;CVE&quot;,&quot;CZK&quot;,&quot;DJF&quot;,&quot;DKK&quot;,&quot;DOP&quot;,&quot;DZD&quot;,&quot;EGP&quot;,&quot;ERN&quot;,&quot;ETB&quot;,&quot;EUR&quot;,&quot;FJD&quot;,&quot;FKP&quot;,&quot;GBP&quot;,&quot;GEL&quot;,&quot;GGP&quot;,&quot;GHS&quot;,&quot;GIP&quot;,&quot;GMD&quot;,&quot;GNF&quot;,&quot;GTQ&quot;,&quot;GYD&quot;,&quot;HKD&quot;,&quot;HNL&quot;,&quot;HRK&quot;,&quot;HTG&quot;,&quot;HUF&quot;,&quot;IDR&quot;,&quot;ILS&quot;,&quot;IMP&quot;,&quot;INR&quot;,&quot;IQD&quot;,&quot;IRT&quot;,&quot;ISK&quot;,&quot;JEP&quot;,&quot;JMD&quot;,&quot;JOD&quot;,&quot;JPY&quot;,&quot;KES&quot;,&quot;KGS&quot;,&quot;KHR&quot;,&quot;KMF&quot;,&quot;KRW&quot;,&quot;KWD&quot;,&quot;KYD&quot;,&quot;KZT&quot;,&quot;LAK&quot;,&quot;LBP&quot;,&quot;LKR&quot;,&quot;LRD&quot;,&quot;LSL&quot;,&quot;LYD&quot;,&quot;MAD&quot;,&quot;MDL&quot;,&quot;MGA&quot;,&quot;MKD&quot;,&quot;MMK&quot;,&quot;MNT&quot;,&quot;MOP&quot;,&quot;MRO&quot;,&quot;MUR&quot;,&quot;MVR&quot;,&quot;MWK&quot;,&quot;MXN&quot;,&quot;MYR&quot;,&quot;MZN&quot;,&quot;NAD&quot;,&quot;NGN&quot;,&quot;NIO&quot;,&quot;NOK&quot;,&quot;NPR&quot;,&quot;NZD&quot;,&quot;OMR&quot;,&quot;PAB&quot;,&quot;PEN&quot;,&quot;PGK&quot;,&quot;PHP&quot;,&quot;PKR&quot;,&quot;PLN&quot;,&quot;PYG&quot;,&quot;QAR&quot;,&quot;RON&quot;,&quot;RSD&quot;,&quot;RUB&quot;,&quot;RWF&quot;,&quot;SAR&quot;,&quot;SBD&quot;,&quot;SCR&quot;,&quot;SEK&quot;,&quot;SGD&quot;,&quot;SHP&quot;,&quot;SLL&quot;,&quot;SOS&quot;,&quot;SRD&quot;,&quot;SSP&quot;,&quot;STD&quot;,&quot;SVC&quot;,&quot;SZL&quot;,&quot;THB&quot;,&quot;TJS&quot;,&quot;TMT&quot;,&quot;TND&quot;,&quot;TOP&quot;,&quot;TRY&quot;,&quot;TTD&quot;,&quot;TWD&quot;,&quot;TZS&quot;,&quot;UAH&quot;,&quot;UGX&quot;,&quot;USD&quot;,&quot;UYU&quot;,&quot;UZS&quot;,&quot;VEF&quot;,&quot;VES&quot;,&quot;VND&quot;,&quot;VUV&quot;,&quot;WST&quot;,&quot;XAF&quot;,&quot;XAG&quot;,&quot;XAU&quot;,&quot;XCD&quot;,&quot;XDR&quot;,&quot;XOF&quot;,&quot;XPD&quot;,&quot;XPF&quot;,&quot;XPT&quot;,&quot;YER&quot;,&quot;ZAR&quot;,&quot;ZMW&quot;,&quot;ZWL&quot;]</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_data</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get wallet balance" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/wallet</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="X-Api-Key" elementType="Header">
<stringProp name="Header.name">X-Api-Key</stringProp>
<stringProp name="Header.value">${inkey}</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
<JSR223Assertion guiclass="TestBeanGUI" testclass="JSR223Assertion" testname="Check Balance is Zero" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())
if (resp.balance !== 0) {
AssertionResult.setFailureMessage(&quot;Balance is not zero, but: &quot;+ resp.balance);
AssertionResult.setFailure(true)
}
</stringProp>
<stringProp name="scriptLanguage">javascript</stringProp>
</JSR223Assertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get payments" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/payments</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="X-Api-Key" elementType="Header">
<stringProp name="Header.name">X-Api-Key</stringProp>
<stringProp name="Header.value">${inkey}</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check empty payment list" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="2914">[]</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_data</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
+487
View File
@@ -0,0 +1,487 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
<hashTree>
<TestFragmentController guiclass="TestFragmentControllerGui" testclass="TestFragmentController" testname="Test Fragment" enabled="false"/>
<hashTree>
<GenericController guiclass="LogicControllerGui" testclass="GenericController" testname="Init Server" enabled="true"/>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Go to home page" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">false</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
<stringProp name="TestPlan.comments">Detected the start of a redirect chain</stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
<stringProp name="50554">307</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">40</intProp>
</ResponseAssertion>
<hashTree/>
<RegexExtractor guiclass="RegexExtractorGui" testclass="RegexExtractor" testname="Extract `is_first_install` var" enabled="true">
<stringProp name="RegexExtractor.useHeaders">true</stringProp>
<stringProp name="RegexExtractor.refname">is_first_install</stringProp>
<stringProp name="RegexExtractor.regex">location: (.*)</stringProp>
<stringProp name="RegexExtractor.template">$1$</stringProp>
<stringProp name="RegexExtractor.default">not-first-install</stringProp>
<stringProp name="RegexExtractor.match_number">0</stringProp>
<stringProp name="Sample.scope">all</stringProp>
</RegexExtractor>
<hashTree/>
</hashTree>
<IfController guiclass="IfControllerPanel" testclass="IfController" testname="If First Install" enabled="true">
<stringProp name="IfController.condition">${__groovy(vars.get(&apos;is_first_install&apos;).contains(&quot;first_install&quot;),)}</stringProp>
<boolProp name="IfController.evaluateAll">false</boolProp>
<boolProp name="IfController.useExpression">true</boolProp>
</IfController>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Go to first_install" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/first_install</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Init super user" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin_password&quot;,&quot;password_repeat&quot;:&quot;admin_password&quot;}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/auth/first_install</stringProp>
<stringProp name="HTTPSampler.method">PUT</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/first_install</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</hashTree>
<IfController guiclass="IfControllerPanel" testclass="IfController" testname="Else (not first install)" enabled="true">
<stringProp name="IfController.condition">${__groovy(!vars.get(&apos;is_first_install&apos;).contains(&quot;first_install&quot;),)}</stringProp>
<boolProp name="IfController.evaluateAll">false</boolProp>
<boolProp name="IfController.useExpression">true</boolProp>
</IfController>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Login super user" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin_password&quot;}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/auth</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Get all admin wallets" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/wallets</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="Extract admin wallet and keys" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())[0]
vars.put(&quot;adminWalletId&quot;, resp.id || &quot;no-admin-wallet-id&quot;);
vars.put(&quot;adminWalletKey&quot;, resp.adminkey || &apos;no-adminkey&apos;);
</stringProp>
<stringProp name="scriptLanguage">javascript</stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Top up wallet" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&quot;amount&quot;:&quot;1000000&quot;,&quot;id&quot;:&quot;${adminWalletId}&quot;}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/admin/api/v1/topup/</stringProp>
<stringProp name="HTTPSampler.method">PUT</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?&amp;wal=c1daa33fc4014ef69cd1505f14f322c2</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Logout super user" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${host}</stringProp>
<stringProp name="HTTPSampler.port">${port}</stringProp>
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/api/v1/auth/logout</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Referer" elementType="Header">
<stringProp name="Header.name">Referer</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
</elementProp>
<elementProp name="Accept-Language" elementType="Header">
<stringProp name="Header.name">Accept-Language</stringProp>
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
</elementProp>
<elementProp name="Origin" elementType="Header">
<stringProp name="Header.name">Origin</stringProp>
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
</elementProp>
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept-Encoding" elementType="Header">
<stringProp name="Header.name">Accept-Encoding</stringProp>
<stringProp name="Header.value">gzip, deflate</stringProp>
</elementProp>
<elementProp name="User-Agent" elementType="Header">
<stringProp name="Header.name">User-Agent</stringProp>
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.custom_message"></stringProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
File diff suppressed because it is too large Load Diff
+3214
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+64 -25
View File
@@ -10,30 +10,36 @@ import traceback
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Callable, List from typing import Callable, List, Optional
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from loguru import logger from loguru import logger
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from lnbits.core.crud import get_installed_extensions from lnbits.core.crud import get_dbversions, get_installed_extensions
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services import websocketUpdater from lnbits.core.services import websocketUpdater
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog, from lnbits.core.tasks import ( # watchdog_task
register_killswitch, killswitch_task,
register_task_listeners, wait_for_paid_invoices,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import cancel_all_tasks, create_permanent_task from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
from lnbits.wallets import get_wallet_class, set_wallet_class from lnbits.wallets import get_wallet_class, set_wallet_class
from .commands import db_versions, load_disabled_extension_list, migrate_databases from .commands import migrate_databases
from .core import init_core_routers from .core import init_core_routers
from .core.db import core_app_extra from .core.db import core_app_extra
from .core.services import check_admin_settings, check_webpush_settings from .core.services import check_admin_settings, check_webpush_settings
@@ -50,6 +56,7 @@ from .middleware import (
CustomGZipMiddleware, CustomGZipMiddleware,
ExtensionsRedirectMiddleware, ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware, InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
@@ -58,7 +65,6 @@ from .tasks import (
check_pending_payments, check_pending_payments,
internal_invoice_listener, internal_invoice_listener,
invoice_listener, invoice_listener,
webhook_handler,
) )
@@ -96,18 +102,22 @@ def create_app() -> FastAPI:
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"] CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
) )
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
# order of these two middlewares is important # order of these two middlewares is important
app.add_middleware(InstalledExtensionMiddleware) app.add_middleware(InstalledExtensionMiddleware)
app.add_middleware(ExtensionsRedirectMiddleware) app.add_middleware(ExtensionsRedirectMiddleware)
register_custom_extensions_path() register_custom_extensions_path()
add_first_install_middleware(app)
# adds security middleware # adds security middleware
add_ip_block_middleware(app) add_ip_block_middleware(app)
add_ratelimit_middleware(app) add_ratelimit_middleware(app)
register_startup(app) register_startup(app)
register_routes(app)
register_async_tasks(app) register_async_tasks(app)
register_exception_handlers(app) register_exception_handlers(app)
register_shutdown(app) register_shutdown(app)
@@ -184,12 +194,11 @@ async def check_installed_extensions(app: FastAPI):
persist state. Zips that are missing will be re-downloaded. persist state. Zips that are missing will be re-downloaded.
""" """
shutil.rmtree(os.path.join("lnbits", "upgrades"), True) shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
await load_disabled_extension_list() installed_extensions = await build_all_installed_extensions_list(False)
installed_extensions = await build_all_installed_extensions_list()
for ext in installed_extensions: for ext in installed_extensions:
try: try:
installed = check_installed_extension_files(ext) installed = await check_installed_extension_files(ext)
if not installed: if not installed:
await restore_installed_extension(app, ext) await restore_installed_extension(app, ext)
logger.info( logger.info(
@@ -207,7 +216,9 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})") logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list() -> List[InstallableExtension]: async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
""" """
Returns a list of all the installed extensions plus the extensions that Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL). MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
@@ -232,17 +243,27 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
) )
installed_extensions.append(ext_info) installed_extensions.append(ext_info)
return installed_extensions if include_deactivated:
return installed_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in installed_extensions
if e.id not in settings.lnbits_deactivated_extensions
]
def check_installed_extension_files(ext: InstallableExtension) -> bool: async def check_installed_extension_files(ext: InstallableExtension) -> bool:
if ext.has_installed_version: if ext.has_installed_version:
return True return True
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip")) zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
if f"./{str(ext.zip_path)}" not in zip_files: if f"./{str(ext.zip_path)}" not in zip_files:
ext.download_archive() await ext.download_archive()
ext.extract_archive() ext.extract_archive()
return False return False
@@ -255,7 +276,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
extension = Extension.from_installable_ext(ext) extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension) register_ext_routes(app, extension)
current_version = (await db_versions()).get(ext.id, 0) current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version) await migrate_extension_database(extension, current_version)
# mount routes for the new version # mount routes for the new version
@@ -268,7 +289,7 @@ def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions.""" """Register FastAPI routes / LNbits extensions."""
init_core_routers(app) init_core_routers(app)
for ext in get_valid_extensions(): for ext in get_valid_extensions(False):
try: try:
register_ext_routes(app, ext) register_ext_routes(app, ext)
except Exception as e: except Exception as e:
@@ -378,6 +399,9 @@ def register_startup(app: FastAPI):
# check extensions after restart # check extensions after restart
await check_installed_extensions(app) await check_installed_extensions(app)
# register core and extension routes
register_routes(app)
if settings.lnbits_admin_ui: if settings.lnbits_admin_ui:
initialize_server_logger() initialize_server_logger()
@@ -444,19 +468,21 @@ def get_db_vendor_name():
def register_async_tasks(app): def register_async_tasks(app):
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.on_event("startup") @app.on_event("startup")
async def listeners(): async def listeners():
create_permanent_task(check_pending_payments) create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
register_task_listeners()
register_killswitch() # core invoice listener
# await run_deferred_async() # calle: doesn't do anyting? invoice_queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
def register_exception_handlers(app: FastAPI): def register_exception_handlers(app: FastAPI):
@@ -515,6 +541,13 @@ def register_exception_handlers(app: FastAPI):
and "accept" in request.headers and "accept" in request.headers
and "text/html" in request.headers["accept"] and "text/html" in request.headers["accept"]
): ):
if exc.headers and "token-expired" in exc.headers:
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie("is_access_token_expired", "true")
return response
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", "error.html",
{ {
@@ -556,6 +589,12 @@ def configure_logger() -> None:
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()] logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").propagate = False logging.getLogger("uvicorn.error").propagate = False
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").propagate = False
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False
class Formatter: class Formatter:
def __init__(self): def __init__(self):
+48 -10
View File
@@ -18,10 +18,13 @@ from lnbits.settings import settings
from .core import db as core_db from .core import db as core_db
from .core import migrations as core_migrations from .core import migrations as core_migrations
from .core.crud import ( from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
get_dbversions, get_dbversions,
get_inactive_extensions, get_inactive_extensions,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
remove_deleted_wallets,
) )
from .core.helpers import migrate_extension_database, run_migration from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE from .db import COCKROACH, POSTGRES, SQLITE
@@ -136,7 +139,11 @@ async def migrate_databases():
core_version = current_versions.get("core", 0) core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version) await run_migration(conn, core_migrations, "core", core_version)
for ext in get_valid_extensions(): # here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0) current_version = current_versions.get(ext.code, 0)
try: try:
await migrate_extension_database(ext, current_version) await migrate_extension_database(ext, current_version)
@@ -147,16 +154,41 @@ async def migrate_databases():
@db.command("versions") @db.command("versions")
def database_versions(): @coro
"""Show current database versions"""
loop = asyncio.get_event_loop()
loop.run_until_complete(db_versions())
async def db_versions(): async def db_versions():
"""Show current database versions""" """Show current database versions"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
return await get_dbversions(conn) click.echo(await get_dbversions(conn))
@db.command("cleanup-wallets")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_wallets(days: Optional[int] = None):
"""Delete all wallets that never had any transaction"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_unused_wallets(delta, conn)
@db.command("cleanup-deleted-wallets")
@coro
async def database_cleanup_deleted_wallets():
"""Delete all wallets that has been marked deleted"""
async with core_db.connect() as conn:
await remove_deleted_wallets(conn)
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
async def load_disabled_extension_list() -> None: async def load_disabled_extension_list() -> None:
@@ -365,7 +397,10 @@ async def install_extension(
return False, "No release selected" return False, "No release selected"
data = CreateExtension( data = CreateExtension(
ext_id=extension, archive=release.archive, source_repo=release.source_repo ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
) )
await _call_install_extension(data, url, admin_user) await _call_install_extension(data, url, admin_user)
click.echo(f"Extension '{extension}' ({release.version}) installed.") click.echo(f"Extension '{extension}' ({release.version}) installed.")
@@ -413,7 +448,10 @@ async def update_extension(
click.echo(f"Updating '{extension}' extension to version: {release.version }") click.echo(f"Updating '{extension}' extension to version: {release.version }")
data = CreateExtension( data = CreateExtension(
ext_id=extension, archive=release.archive, source_repo=release.source_repo ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
) )
await _call_install_extension(data, url, admin_user) await _call_install_extension(data, url, admin_user)
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from .db import core_app_extra, db from .db import core_app_extra, db
from .views.admin_api import admin_router from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.auth_api import auth_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension from .views.generic import generic_router, update_user_extension
@@ -26,3 +27,4 @@ def init_core_routers(app):
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(auth_router)
+266 -12
View File
@@ -1,10 +1,12 @@
import datetime import datetime
import json import json
from time import time
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import shortuuid import shortuuid
from passlib.context import CryptContext
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import WalletType from lnbits.core.models import WalletType
@@ -20,11 +22,14 @@ from lnbits.settings import (
from .models import ( from .models import (
BalanceCheck, BalanceCheck,
CreateUser,
Payment, Payment,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
TinyURL, TinyURL,
UpdateUserPassword,
User, User,
UserConfig,
Wallet, Wallet,
WebPushSubscription, WebPushSubscription,
) )
@@ -33,8 +38,48 @@ from .models import (
# -------- # --------
async def create_user(
data: CreateUser, user_config: Optional[UserConfig] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if await get_account_by_username(data.username):
raise ValueError("Username already exists.")
if data.email and await get_account_by_email(data.email):
raise ValueError("Email already exists.")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
user_id = uuid4().hex
tsph = db.timestamp_placeholder
now = int(time())
await db.execute(
f"""
INSERT INTO accounts
(id, email, username, pass, extra, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, {tsph}, {tsph})
""",
(
user_id,
data.email,
data.username,
pwd_context.hash(data.password),
json.dumps(dict(user_config)) if user_config else "{}",
now,
now,
),
)
new_account = await get_account(user_id=user_id)
assert new_account, "Newly created account couldn't be retrieved"
return new_account
async def create_account( async def create_account(
conn: Optional[Connection] = None, user_id: Optional[str] = None conn: Optional[Connection] = None,
user_id: Optional[str] = None,
email: Optional[str] = None,
user_config: Optional[UserConfig] = None,
) -> User: ) -> User:
if user_id: if user_id:
user_uuid4 = UUID(hex=user_id, version=4) user_uuid4 = UUID(hex=user_id, version=4)
@@ -42,7 +87,15 @@ async def create_account(
else: else:
user_id = uuid4().hex user_id = uuid4().hex
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) extra = json.dumps(dict(user_config)) if user_config else "{}"
now = int(time())
await (conn or db).execute(
f"""
INSERT INTO accounts (id, email, extra, created_at, updated_at)
VALUES (?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
""",
(user_id, email, extra, now, now),
)
new_account = await get_account(user_id=user_id, conn=conn) new_account = await get_account(user_id=user_id, conn=conn)
assert new_account, "Newly created account couldn't be retrieved" assert new_account, "Newly created account couldn't be retrieved"
@@ -50,19 +103,176 @@ async def create_account(
return new_account return new_account
async def update_account(
user_id: str,
username: Optional[str] = None,
email: Optional[str] = None,
user_config: Optional[UserConfig] = None,
) -> Optional[User]:
user = await get_account(user_id)
assert user, "User not found"
if email:
assert not user.email or email == user.email, "Cannot change email."
account = await get_account_by_email(email)
assert not account or account.id == user_id, "Email already in use."
if username:
assert not user.username or username == user.username, "Cannot change username."
account = await get_account_by_username(username)
assert not account or account.id == user_id, "Username already in exists."
username = user.username or username
email = user.email or email
extra = user_config or user.config
now = int(time())
await db.execute(
f"""
UPDATE accounts SET (username, email, extra, updated_at) =
(?, ?, ?, {db.timestamp_placeholder})
WHERE id = ?
""",
(
username,
email,
json.dumps(dict(extra)) if extra else "{}",
now,
user_id,
),
)
user = await get_user(user_id)
assert user, "Updated account couldn't be retrieved"
return user
async def get_account( async def get_account(
user_id: str, conn: Optional[Connection] = None user_id: str, conn: Optional[Connection] = None
) -> Optional[User]: ) -> Optional[User]:
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
"SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,) """
SELECT id, email, username, created_at, updated_at, extra
FROM accounts WHERE id = ?
""",
(user_id,),
)
user = User(**row) if row else None
if user and row["extra"]:
user.config = UserConfig(**json.loads(row["extra"]))
return user
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND updated_at < {db.timestamp_placeholder}
""",
(int(time()) - time_delta,),
)
async def get_user_password(user_id: str) -> Optional[str]:
row = await db.fetchone(
"SELECT pass FROM accounts WHERE id = ?",
(user_id,),
)
if not row:
return None
return row[0]
async def verify_user_password(user_id: str, password: str) -> bool:
existing_password = await get_user_password(user_id)
if not existing_password:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, existing_password)
# todo: , conn: Optional[Connection] = None ??
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
assert data.password == data.password_repeat, "Passwords do not match."
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
now = int(time())
await db.execute(
f"""
UPDATE accounts SET pass = ?, updated_at = {db.timestamp_placeholder}
WHERE id = ?
""",
(
pwd_context.hash(data.password),
now,
data.user_id,
),
)
user = await get_user(data.user_id)
assert user, "Updated account couldn't be retrieved"
return user
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
FROM accounts WHERE username = ?
""",
(username,),
) )
return User(**row) if row else None return User(**row) if row else None
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
FROM accounts WHERE email = ?
""",
(email,),
)
return User(**row) if row else None
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[User]:
user = await get_account_by_username(username_or_email, conn)
if not user:
user = await get_account_by_email(username_or_email, conn)
return user
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]: async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
user = await (conn or db).fetchone( user = await (conn or db).fetchone(
"SELECT id, email FROM accounts WHERE id = ?", (user_id,) """
SELECT id, email, username, pass, extra, created_at, updated_at
FROM accounts WHERE id = ?
""",
(user_id,),
) )
if user: if user:
@@ -86,6 +296,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
return User( return User(
id=user["id"], id=user["id"],
email=user["email"], email=user["email"],
username=user["username"],
extensions=[ extensions=[
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"]) e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
], ],
@@ -93,6 +304,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
admin=user["id"] == settings.super_user admin=user["id"] == settings.super_user
or user["id"] in settings.lnbits_admin_users, or user["id"] in settings.lnbits_admin_users,
super_user=user["id"] == settings.super_user, super_user=user["id"] == settings.super_user,
has_password=True if user["pass"] else False,
config=UserConfig(**json.loads(user["extra"])) if user["extra"] else None,
) )
@@ -109,6 +322,7 @@ async def add_installed_extension(
dict(ext.installed_release) if ext.installed_release else None dict(ext.installed_release) if ext.installed_release else None
), ),
"dependencies": ext.dependencies, "dependencies": ext.dependencies,
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
} }
version = ext.installed_release.version if ext.installed_release else "" version = ext.installed_release.version if ext.installed_release else ""
@@ -233,10 +447,11 @@ async def create_wallet(
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Wallet: ) -> Wallet:
wallet_id = uuid4().hex wallet_id = uuid4().hex
now = int(time())
await (conn or db).execute( await (conn or db).execute(
""" f"""
INSERT INTO wallets (id, name, "user", adminkey, inkey) INSERT INTO wallets (id, name, "user", adminkey, inkey, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
""", """,
( (
wallet_id, wallet_id,
@@ -244,6 +459,8 @@ async def create_wallet(
user_id, user_id,
uuid4().hex, uuid4().hex,
uuid4().hex, uuid4().hex,
now,
now,
), ),
) )
@@ -260,7 +477,10 @@ async def update_wallet(
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[Wallet]: ) -> Optional[Wallet]:
set_clause = [] set_clause = []
values = [] values: list = []
set_clause.append(f"updated_at = {db.timestamp_placeholder}")
now = int(time())
values.append(now)
if name: if name:
set_clause.append("name = ?") set_clause.append("name = ?")
values.append(name) values.append(name)
@@ -282,13 +502,33 @@ async def update_wallet(
async def delete_wallet( async def delete_wallet(
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None *, user_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None: ) -> None:
now = int(time())
await (conn or db).execute( await (conn or db).execute(
""" f"""
UPDATE wallets UPDATE wallets
SET deleted = true SET deleted = true, updated_at = {db.timestamp_placeholder}
WHERE id = ? AND "user" = ? WHERE id = ? AND "user" = ?
""", """,
(wallet_id, user_id), (now, wallet_id, user_id),
)
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
f"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
) = 0 AND updated_at < {db.timestamp_placeholder}
""",
(int(time()) - time_delta,),
) )
@@ -314,7 +554,8 @@ async def get_wallet_for_key(
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
""" """
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ? AS balance_msat FROM wallets
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
""", """,
(key, key), (key, key),
) )
@@ -362,6 +603,7 @@ async def get_standalone_payment(
SELECT * SELECT *
FROM apipayments FROM apipayments
WHERE {clause} WHERE {clause}
ORDER BY amount
LIMIT 1 LIMIT 1
""", """,
tuple(values), tuple(values),
@@ -773,6 +1015,16 @@ async def check_internal_pending(
return row["pending"] return row["pending"]
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(status, payment_hash),
)
# balance_check # balance_check
# ------------- # -------------
@@ -858,6 +1110,8 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
return None return None
row_dict = dict(sets) row_dict = dict(sets)
row_dict.pop("super_user") row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings( admin_settings = AdminSettings(
is_super_user=is_super_user, is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources, lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
+51 -11
View File
@@ -1,6 +1,6 @@
import importlib import importlib
import re import re
from typing import Any from typing import Any, Optional
from uuid import UUID from uuid import UUID
import httpx import httpx
@@ -48,24 +48,64 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(ext_id: str, user: str): async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
""" """
Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a DELETE enpoint at the root level of their API. Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
This function tries first to call the endpoint using `http` at the root level of their API.
and if it fails it tries using `https`.
""" """
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
await getattr(old_module, stop_fn_name)()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}" url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
await client.delete(url) headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex: except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex) logger.warning(ex)
try:
# try https
url = f"https://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
except Exception as ex:
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
+78
View File
@@ -1,4 +1,5 @@
import datetime import datetime
from time import time
from loguru import logger from loguru import logger
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
@@ -393,3 +394,80 @@ async def m015_create_push_notification_subscriptions_table(db):
); );
""" """
) )
async def m016_add_username_column_to_accounts(db):
"""
Adds username column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN username TEXT")
await db.execute("ALTER TABLE accounts ADD COLUMN extra TEXT")
except OperationalError:
pass
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
"""
Adds created_at and updated_at column to accounts and wallets.
"""
try:
await db.execute(
"ALTER TABLE accounts "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
await db.execute(
"ALTER TABLE accounts "
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
await db.execute(
"ALTER TABLE wallets "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
await db.execute(
"ALTER TABLE wallets "
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
# # set their wallets created_at with the first payment
# await db.execute(
# """
# UPDATE wallets SET created_at = (
# SELECT time FROM apipayments
# WHERE apipayments.wallet = wallets.id
# ORDER BY time ASC LIMIT 1
# )
# """
# )
# # then set their accounts created_at with the wallet
# await db.execute(
# """
# UPDATE accounts SET created_at = (
# SELECT created_at FROM wallets
# WHERE wallets.user = accounts.id
# ORDER BY created_at ASC LIMIT 1
# )
# """
# )
# set all to now where they are null
now = int(time())
await db.execute(
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""",
(now,),
)
await db.execute(
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""",
(now,),
)
except OperationalError as exc:
logger.error(f"Migration 17 failed: {exc}")
pass
+66 -6
View File
@@ -18,18 +18,23 @@ from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_wallet_class
from lnbits.wallets.base import PaymentStatus from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
class Wallet(BaseModel): class BaseWallet(BaseModel):
id: str id: str
name: str name: str
user: str
adminkey: str adminkey: str
inkey: str inkey: str
currency: Optional[str]
balance_msat: int balance_msat: int
class Wallet(BaseWallet):
user: str
currency: Optional[str]
deleted: bool deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property @property
def balance(self) -> int: def balance(self) -> int:
@@ -79,14 +84,31 @@ class WalletTypeInfo:
wallet: Wallet wallet: Wallet
class UserConfig(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class User(BaseModel): class User(BaseModel):
id: str id: str
email: Optional[str] = None email: Optional[str] = None
username: Optional[str] = None
extensions: List[str] = [] extensions: List[str] = []
wallets: List[Wallet] = [] wallets: List[Wallet] = []
password: Optional[str] = None
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property @property
def wallet_ids(self) -> List[str]: def wallet_ids(self) -> List[str]:
@@ -107,6 +129,43 @@ class User(BaseModel):
return False return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
class UpdateUserPassword(BaseModel):
user_id: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class Payment(FromRowModel): class Payment(FromRowModel):
checking_id: str checking_id: str
pending: bool pending: bool
@@ -199,7 +258,7 @@ class Payment(FromRowModel):
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> PaymentStatus: ) -> PaymentStatus:
if self.is_uncheckable: if self.is_uncheckable:
return PaymentStatus(None) return PaymentPendingStatus()
logger.debug( logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} " f"Checking {'outgoing' if self.is_out else 'incoming'} "
@@ -323,6 +382,7 @@ class CreateLnurl(BaseModel):
amount: int amount: int
comment: Optional[str] = None comment: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel): class CreateInvoice(BaseModel):
+97 -20
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import datetime import datetime
import json import json
import time
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict from typing import Dict, List, Optional, Tuple, TypedDict
@@ -30,7 +31,12 @@ from lnbits.settings import (
) )
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
from lnbits.wallets.base import PaymentResponse, PaymentStatus from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from .crud import ( from .crud import (
check_internal, check_internal,
@@ -41,6 +47,7 @@ from .crud import (
create_wallet, create_wallet,
delete_wallet_payment, delete_wallet_payment,
get_account, get_account,
get_payments,
get_standalone_payment, get_standalone_payment,
get_super_settings, get_super_settings,
get_total_balance, get_total_balance,
@@ -52,7 +59,7 @@ from .crud import (
update_super_user, update_super_user,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import Payment, Wallet from .models import Payment, UserConfig, Wallet
class PaymentFailure(Exception): class PaymentFailure(Exception):
@@ -118,8 +125,9 @@ async def create_invoice(
if not amount > 0: if not amount > 0:
raise InvoiceFailure("Amountless invoices not supported.") raise InvoiceFailure("Amountless invoices not supported.")
if await get_wallet(wallet_id, conn=conn) is None: user_wallet = await get_wallet(wallet_id, conn=conn)
raise InvoiceFailure("Wallet does not exist.") if not user_wallet:
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
invoice_memo = None if description_hash else memo invoice_memo = None if description_hash else memo
@@ -130,6 +138,14 @@ async def create_invoice(
amount, wallet_id, currency=currency, extra=extra, conn=conn amount, wallet_id, currency=currency, extra=extra, conn=conn
) )
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceFailure(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats."
)
ok, checking_id, payment_request, error_message = await wallet.create_invoice( ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount_sat, amount=amount_sat,
memo=invoice_memo, memo=invoice_memo,
@@ -178,22 +194,22 @@ async def pay_invoice(
If the payment is still in flight, we hope that some other process If the payment is still in flight, we hope that some other process
will regularly check for the payment. will regularly check for the payment.
""" """
invoice = bolt11_decode(payment_request) try:
invoice = bolt11_decode(payment_request)
except Exception:
raise InvoiceFailure("Bolt11 decoding failed.")
if not invoice.amount_msat or not invoice.amount_msat > 0: if not invoice.amount_msat or not invoice.amount_msat > 0:
raise ValueError("Amountless invoices not supported.") raise InvoiceFailure("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.") raise InvoiceFailure("Amount in invoice is too high.")
await check_wallet_limits(wallet_id, conn, 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 temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}" internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
_, extra = await calculate_fiat_amounts( _, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
) )
@@ -372,6 +388,58 @@ async def pay_invoice(
return invoice.payment_hash return invoice.payment_hash
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise ValueError(
f"The time limit of {limit} seconds between payments has been reached."
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw( async def redeem_lnurl_withdraw(
wallet_id: str, wallet_id: str,
lnurl_request: str, lnurl_request: str,
@@ -514,10 +582,10 @@ async def check_transaction_status(
wallet_id, payment_hash, conn=conn wallet_id, payment_hash, conn=conn
) )
if not payment: if not payment:
return PaymentStatus(None) return PaymentPendingStatus()
if not payment.pending: if not payment.pending:
# note: before, we still checked the status of the payment again # note: before, we still checked the status of the payment again
return PaymentStatus(True, fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status() status: PaymentStatus = await payment.check_status()
return status return status
@@ -611,6 +679,10 @@ async def check_admin_settings():
): ):
send_admin_user_to_saas() send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
@@ -642,11 +714,14 @@ async def check_webpush_settings():
def update_cached_settings(sets_dict: dict): def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items(): for key, value in sets_dict.items():
if key not in readonly_variables: if key in readonly_variables:
try: continue
setattr(settings, key, value) if key not in settings.dict().keys():
except Exception: continue
logger.warning(f"Failed overriding setting: {key}, value: {value}") try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict: if "super_user" in sets_dict:
setattr(settings, "super_user", sets_dict["super_user"]) setattr(settings, "super_user", sets_dict["super_user"])
@@ -656,7 +731,9 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
if super_user: if super_user:
account = await get_account(super_user) account = await get_account(super_user)
if not account: if not account:
account = await create_account(user_id=super_user) account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
)
if not account.wallets or len(account.wallets) == 0: if not account.wallets or len(account.wallets) == 0:
await create_wallet(user_id=account.id) await create_wallet(user_id=account.id)
+37
View File
@@ -0,0 +1,37 @@
"""Keycloak SSO Login Helper
"""
from typing import Optional
import httpx
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
class KeycloakSSO(SSOBase):
"""Class providing login via Keycloak OAuth"""
provider = "keycloak"
scope = ["openid", "email", "profile"]
discovery_url = ""
async def openid_from_response(
self, response: dict, session: Optional["httpx.AsyncClient"] = None
) -> OpenID:
"""Return OpenID from user information provided by Keycloak"""
return OpenID(
email=response.get("email", ""),
provider=self.provider,
id=response.get("sub"),
first_name=response.get("given_name"),
last_name=response.get("family_name"),
display_name=response.get("name"),
picture=response.get("picture"),
)
async def get_discovery_document(self) -> DiscoveryDocument:
"""Get document containing handy urls"""
async with httpx.AsyncClient() as session:
response = await session.get(self.discovery_url)
content = response.json()
return content
+46 -64
View File
@@ -8,8 +8,8 @@ from lnbits.core.crud import (
get_balance_notify, get_balance_notify,
get_wallet, get_wallet,
get_webpush_subscriptions_for_user, get_webpush_subscriptions_for_user,
mark_webhook_sent,
) )
from lnbits.core.db import db
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import ( from lnbits.core.services import (
get_balance_delta, get_balance_delta,
@@ -17,29 +17,16 @@ from lnbits.core.services import (
switch_to_voidwallet, switch_to_voidwallet,
) )
from lnbits.settings import get_wallet_class, settings from lnbits.settings import get_wallet_class, settings
from lnbits.tasks import ( from lnbits.tasks import send_push_notification
SseListenersDict,
create_permanent_task,
create_task,
register_invoice_listener,
send_push_notification,
)
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict( api_invoice_listeners: Dict[str, asyncio.Queue] = {}
"api_invoice_listeners"
)
def register_killswitch():
"""
Registers a killswitch which will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
logger.debug("Starting killswitch task")
create_permanent_task(killswitch_task)
async def killswitch_task(): async def killswitch_task():
"""
killswitch will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
while True: while True:
WALLET = get_wallet_class() WALLET = get_wallet_class()
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet": if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
@@ -54,7 +41,7 @@ async def killswitch_task():
"Switching to VoidWallet. Killswitch triggered." "Switching to VoidWallet. Killswitch triggered."
) )
await switch_to_voidwallet() await switch_to_voidwallet()
except (httpx.ConnectError, httpx.RequestError): except (httpx.RequestError, httpx.HTTPStatusError):
logger.error( logger.error(
"Cannot fetch lnbits status manifest." "Cannot fetch lnbits status manifest."
f" {settings.lnbits_status_manifest}" f" {settings.lnbits_status_manifest}"
@@ -62,17 +49,11 @@ async def killswitch_task():
await asyncio.sleep(settings.lnbits_killswitch_interval * 60) await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
async def register_watchdog(): async def watchdog_task():
""" """
Registers a watchdog which will check lnbits balance and nodebalance Registers a watchdog which will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached. and will switch to VoidWallet if the watchdog delta is reached.
""" """
# TODO: implement watchdog properly
# logger.debug("Starting watchdog task")
# create_permanent_task(watchdog_task)
async def watchdog_task():
while True: while True:
WALLET = get_wallet_class() WALLET = get_wallet_class()
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet": if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
@@ -87,36 +68,23 @@ async def watchdog_task():
await asyncio.sleep(settings.lnbits_watchdog_interval * 60) await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
def register_task_listeners():
"""
Registers an invoice listener queue for the core tasks. Incoming payments in this
queue will eventually trigger the signals sent to all other extensions
and fulfill other core tasks such as dispatching webhooks.
"""
invoice_paid_queue = asyncio.Queue(5)
# we register invoice_paid_queue to receive all incoming invoices
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
# register a worker that will react to invoices
create_task(wait_for_paid_invoices(invoice_paid_queue))
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
""" """
This worker dispatches events to all extensions, This task dispatches events to all api_invoice_listeners,
dispatches webhooks and balance notifys. webhooks, push notifications and balance notifications.
""" """
while True: while True:
payment = await invoice_paid_queue.get() payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event") logger.trace("received invoice paid event")
# send information to sse channel # dispatch api_invoice_listeners
await dispatch_api_invoice_listeners(payment) await dispatch_api_invoice_listeners(payment)
# payment notification
wallet = await get_wallet(payment.wallet_id) wallet = await get_wallet(payment.wallet_id)
if wallet: if wallet:
await send_payment_notification(wallet, payment) await send_payment_notification(wallet, payment)
# dispatch webhook # dispatch webhook
if payment.webhook and not payment.webhook_status: if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment) await dispatch_webhook(payment)
# dispatch balance_notify # dispatch balance_notify
url = await get_balance_notify(payment.wallet_id) url = await get_balance_notify(payment.wallet_id)
if url: if url:
@@ -124,10 +92,22 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
r = await client.post(url, timeout=4) r = await client.post(url, timeout=4)
await mark_webhook_sent(payment, r.status_code) r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment.payment_hash, r.status_code)
pass except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
await mark_webhook_sent(payment.payment_hash, status_code)
logger.warning(
f"balance_notify returned a bad status_code: {status_code} "
f"while requesting {exc.request.url!r}."
)
logger.warning(exc)
except httpx.RequestError as exc:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send balance_notify to {url}")
logger.warning(exc)
# dispatch push notification
await send_payment_push_notification(payment) await send_payment_push_notification(payment)
@@ -137,10 +117,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
""" """
for chan_name, send_channel in api_invoice_listeners.items(): for chan_name, send_channel in api_invoice_listeners.items():
try: try:
logger.debug(f"sending invoice paid event to {chan_name}") logger.debug(f"api invoice listener: sending paid event to {chan_name}")
send_channel.put_nowait(payment) send_channel.put_nowait(payment)
except asyncio.QueueFull: except asyncio.QueueFull:
logger.error(f"removing sse listener {send_channel}:{chan_name}") logger.error(
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
)
api_invoice_listeners.pop(chan_name) api_invoice_listeners.pop(chan_name)
@@ -151,26 +133,24 @@ async def dispatch_webhook(payment: Payment):
logger.debug("sending webhook", payment.webhook) logger.debug("sending webhook", payment.webhook)
if not payment.webhook: if not payment.webhook:
return await mark_webhook_sent(payment, -1) return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict() data = payment.dict()
try: try:
r = await client.post(payment.webhook, json=data, timeout=40) r = await client.post(payment.webhook, json=data, timeout=40)
await mark_webhook_sent(payment, r.status_code) r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment.payment_hash, r.status_code)
await mark_webhook_sent(payment, -1) except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
async def mark_webhook_sent(payment: Payment, status: int) -> None: f"webhook returned a bad status_code: {exc.response.status_code} "
await db.execute( f"while requesting {exc.request.url!r}."
""" )
UPDATE apipayments SET webhook_status = ? except httpx.RequestError:
WHERE hash = ? await mark_webhook_sent(payment.payment_hash, -1)
""", logger.warning(f"Could not send webhook to {payment.webhook}")
(status, payment.payment_hash),
)
async def send_payment_push_notification(payment: Payment): async def send_payment_push_notification(payment: Payment):
@@ -188,6 +168,8 @@ async def send_payment_push_notification(payment: Payment):
body += f"\r\n{payment.memo}" body += f"\r\n{payment.memo}"
for subscription in subscriptions: for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = ( url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}" f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
) )
+15 -73
View File
@@ -7,21 +7,21 @@
<div class="col"> <div class="col">
<p>Funding Source Info</p> <p>Funding Source Info</p>
<ul> <ul>
{%raw%} <li
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li> v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
<li> ></li>
Node Balance: {{(auditData.node_balance_msats / <li
1000).toLocaleString()}} sats v-text="'Node Balance: ' + (auditData.node_balance_msats /
</li> 1000).toLocaleString() + ' sats'"
<li> ></li>
LNbits Balance: {{(auditData.lnbits_balance_msats / <li
1000).toLocaleString()}} sats v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
</li> 1000).toLocaleString() + ' sats'"
<li> ></li>
Reserve Percent: {{(auditData.node_balance_msats / <li
auditData.lnbits_balance_msats * 100).toFixed(2)}} % v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
</li> auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
{%endraw%} ></li>
</ul> </ul>
<br /> <br />
</div> </div>
@@ -85,64 +85,6 @@
</div> </div>
</div> </div>
</div> </div>
<h6 class="q-mt-xl q-mb-md">Service Fees</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Service Fee</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee"
label="Service fee (%)"
step="0.1"
hint="Fee charged per tx (%)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Service fee max</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee_max"
label="Service fee max (sats)"
hint="Max service fee to charge in (sats)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Fee Wallet</p>
<q-input
filled
v-model="formData.lnbits_service_fee_wallet"
label="Fee wallet (wallet ID)"
hint="Wallet ID to send funds to"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Disable Service Fee for Internal Payments</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Fee</q-item-label>
<q-item-label caption
>Disable Service Fee for Internal Lightning
Payments</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_service_fee_ignore_internal"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<div v-if="isSuperUser"> <div v-if="isSuperUser">
<lnbits-funding-sources <lnbits-funding-sources
:form-data="formData" :form-data="formData"
+152 -12
View File
@@ -1,7 +1,122 @@
<q-tab-panel name="security"> <q-tab-panel name="security">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none" v-text="$t('security_tools')"></h6> <h6 class="q-my-none q-mb-sm">Authentication</h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.auth_token_expire_minutes"
type="number"
label="Token expire minutes"
hint="Time in minutes until the token expires"
>
</q-input>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-select
filled
v-model="formData.auth_allowed_methods"
multiple
hint="Allowed authorization methods"
label="Select authorization methods"
:options="formData.auth_all_methods"
></q-select>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Google Auth</strong>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.google_client_id"
label="Google Client ID"
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
>
</q-input>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.google_client_secret"
type="password"
label="Google Client Secret"
>
</q-input>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('github-auth')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">GitHub Auth</strong>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.github_client_id"
label="GitHub Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
>
</q-input>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.github_client_secret"
type="password"
label="GitHub Client Secret"
>
</q-input>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('keycloak-auth')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row">
<div class="col-md-4 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.keycloak_discovery_url"
label="Keycloak Discovey URL"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.keycloak_client_id"
label="Keycloak Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_secret"
type="password"
label="Keycloak Client Secret"
>
</q-input>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pa-none">
<br /> <br />
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
<div> <div>
<div class="row"> <div class="row">
<div v-if="serverlogEnabled" class="column" style="width: 100%"> <div v-if="serverlogEnabled" class="column" style="width: 100%">
@@ -16,7 +131,7 @@
style="padding: 10px; color: #fafafa; height: 320px" style="padding: 10px; color: #fafafa; height: 320px"
> >
<small v-for="log in logs" <small v-for="log in logs"
>{% raw %}{{ log }}{% endraw %}<br ><span v-text="log"></span><br
/></small> /></small>
</q-scroll-area> </q-scroll-area>
</div> </div>
@@ -51,7 +166,6 @@
></q-btn> ></q-btn>
</q-input> </q-input>
<div> <div>
{%raw%}
<q-chip <q-chip
v-for="blocked_ip in formData.lnbits_blocked_ips" v-for="blocked_ip in formData.lnbits_blocked_ips"
:key="blocked_ip" :key="blocked_ip"
@@ -59,10 +173,8 @@
@remove="removeBlockedIPs(blocked_ip)" @remove="removeBlockedIPs(blocked_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
> v-text="blocked_ip"
{{ blocked_ip }} ></q-chip>
</q-chip>
{%endraw%}
</div> </div>
<br /> <br />
</div> </div>
@@ -83,7 +195,6 @@
></q-btn> ></q-btn>
</q-input> </q-input>
<div> <div>
{%raw%}
<q-chip <q-chip
v-for="allowed_ip in formData.lnbits_allowed_ips" v-for="allowed_ip in formData.lnbits_allowed_ips"
:key="allowed_ip" :key="allowed_ip"
@@ -91,15 +202,14 @@
@remove="removeAllowedIPs(allowed_ip)" @remove="removeAllowedIPs(allowed_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
> v-text="allowed_ip"
{{ allowed_ip }} ></q-chip>
</q-chip>
{%endraw%}
</div> </div>
<br /> <br />
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -121,6 +231,36 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12">
<p v-text="$t('wallet_limiter')"></p>
<div class="row q-col-gutter-md">
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
</div>
</div>
</div> </div>
</div> </div>
<br /> <br />
@@ -1,4 +1,3 @@
{% raw %}
<q-banner v-if="updateAvailable" class="bg-primary text-white"> <q-banner v-if="updateAvailable" class="bg-primary text-white">
<q-icon size="28px" name="update"></q-icon> <q-icon size="28px" name="update"></q-icon>
@@ -30,9 +29,12 @@
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width> </q-th> <q-th auto-width> </q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" <q-th
>{{ col.label }}</q-th v-for="col in props.cols"
> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
@@ -51,12 +53,16 @@
color="red" color="red"
></q-icon> ></q-icon>
</q-td> </q-td>
<q-td auto-width key="date" :props="props"> <q-td
{{ formatDate(props.row.date) }} auto-width
key="date"
:props="props"
v-text="formatDate(props.row.date)"
>
</q-td> </q-td>
<q-td key="message" :props="props" <q-td key="message" :props="props"
>{{ props.row.message }} ><span v-text="props.row.message"></span
<a ><a
v-if="props.row.link" v-if="props.row.link"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -69,4 +75,3 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
{% endraw %}
+70 -13
View File
@@ -7,14 +7,14 @@
<div class="col"> <div class="col">
<p>Server Info</p> <p>Server Info</p>
<ul> <ul>
{%raw%} <li
<li v-if="settings.lnbits_data_folder"> v-if="settings.lnbits_data_folder"
SQlite: {{settings.lnbits_data_folder}} v-text="'SQlite: ' + settings.lnbits_data_folder"
</li> ></li>
<li v-if="settings.lnbits_database_url"> <li
Postgres: {{settings.lnbits_database_url}} v-if="settings.lnbits_database_url"
</li> v-text="'Postgres: ' + settings.lnbits_database_url"
{%endraw%} ></li>
</ul> </ul>
<br /> <br />
</div> </div>
@@ -80,7 +80,67 @@
<br /> <br />
</div> </div>
</div> </div>
<br />
<h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Service Fee</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee"
label="Service fee (%)"
step="0.1"
hint="Fee charged per tx (%)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Service fee max</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee_max"
label="Service fee max (sats)"
hint="Max service fee to charge in (sats)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Fee Wallet</p>
<q-input
filled
v-model="formData.lnbits_service_fee_wallet"
label="Fee wallet (wallet ID)"
hint="Wallet ID to send funds to"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Disable Service Fee for Internal Payments</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Fee</q-item-label>
<q-item-label caption
>Disable Service Fee for Internal Lightning
Payments</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_service_fee_ignore_internal"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<h6 class="q-my-none">Extensions</h6>
<div> <div>
<p>Extension Sources</p> <p>Extension Sources</p>
<q-input <q-input
@@ -94,7 +154,6 @@
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn> <q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input> </q-input>
<div> <div>
{%raw%}
<q-chip <q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests" v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl" :key="manifestUrl"
@@ -102,10 +161,8 @@
@remove="removeExtensionsManifest(manifestUrl)" @remove="removeExtensionsManifest(manifestUrl)"
color="primary" color="primary"
text-color="white" text-color="white"
> v-text="manifestUrl"
{{ manifestUrl }} ></q-chip>
</q-chip>
{%endraw%}
</div> </div>
<br /> <br />
</div> </div>
+69 -53
View File
@@ -1,62 +1,78 @@
<q-tab-panel name="users"> <q-tab-panel name="users">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none">User Management</h6> <h6 class="q-my-none q-mb-sm">User Management</h6>
<br />
<div> <div class="row">
<p>Admin Users</p> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-input <p>Admin Users</p>
filled <q-input
v-model="formAddAdmin" filled
@keydown.enter="addAdminUser" v-model="formAddAdmin"
type="text" @keydown.enter="addAdminUser"
label="User ID" type="text"
hint="Users with admin privileges" label="User ID"
> hint="Users with admin privileges"
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="user in formData.lnbits_admin_users"
:key="user"
removable
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
> >
{{ user }} <q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-chip> </q-input>
{%endraw%} <div>
<q-chip
v-for="user in formData.lnbits_admin_users"
:key="user"
removable
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
>
<span v-text="user" />
</q-chip>
</div>
<br />
</div> </div>
<br /> <div class="col-md-6 col-sm-12">
</div> <p>Allowed Users</p>
<div> <q-input
<p>Allowed Users</p> filled
<q-input v-model="formAddUser"
filled @keydown.enter="addAllowedUser"
v-model="formAddUser" type="text"
@keydown.enter="addAllowedUser" label="User ID"
type="text" hint="Only these users can use LNbits"
label="User ID"
hint="Only these users can use LNbits"
>
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-input>
<div>
{% raw %}
<q-chip
v-for="user in formData.lnbits_allowed_users"
:key="user"
removable
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
> >
{{ user }} <q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-chip> </q-input>
{% endraw %} <div>
<q-chip
v-for="user in formData.lnbits_allowed_users"
:key="user"
removable
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
>
<span v-text="user" />
</q-chip>
</div>
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Allow creation of new users</q-item-label>
<q-item-label caption
>Allow creation of new users on the index page</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_allow_new_accounts"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div> </div>
<br />
</div> </div>
</q-card-section> </q-card-section>
</q-tab-panel> </q-tab-panel>
+12 -8
View File
@@ -8,9 +8,9 @@
@click="updateSettings" @click="updateSettings"
:disabled="!checkChanges" :disabled="!checkChanges"
> >
<q-tooltip v-if="checkChanges" <q-tooltip v-if="checkChanges">
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip <span v-text="$t('save_tooltip')"></span>
> </q-tooltip>
<q-badge <q-badge
v-if="checkChanges" v-if="checkChanges"
@@ -27,9 +27,9 @@
color="primary" color="primary"
@click="restartServer" @click="restartServer"
> >
<q-tooltip v-if="needsRestart" <q-tooltip v-if="needsRestart">
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip <span v-text="$t('restart_tooltip')"></span>
> </q-tooltip>
<q-badge <q-badge
v-if="needsRestart" v-if="needsRestart"
@@ -46,7 +46,9 @@
color="primary" color="primary"
@click="topUpDialog.show = true" @click="topUpDialog.show = true"
> >
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip> <q-tooltip>
<span v-text="$t('add_funds_tooltip')"></span>
</q-tooltip>
</q-btn> </q-btn>
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn> <q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
@@ -59,7 +61,9 @@
@click="deleteSettings" @click="deleteSettings"
class="float-right" class="float-right"
> >
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip> <q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</div> </div>
+3 -2
View File
@@ -1,10 +1,11 @@
<q-expansion-item <q-expansion-item
group="extras" group="extras"
icon="swap_vertical_circle" icon="vpn_key"
:label="$t('api_docs')" :label="$t('api_keys_api_docs')"
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card-section> <q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br /> <strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br /> <strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em> <strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
+436
View File
@@ -0,0 +1,436 @@
{% extends "base.html" %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tabs v-model="tab" align="justify">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="passwordData.show">
<q-card-section>
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password_config')"></span>
</h4>
</div>
<div class="col">
<q-img
v-if="user.config.picture"
style="max-width: 100px"
:src="user.config.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-if="user.has_password"
v-model="passwordData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
unelevated
color="primary"
:label="$t('change_password')"
>
</q-btn>
<q-btn
@click="passwordData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
<div v-else>
<q-card-section>
<div class="row">
<div class="col">
<q-img
v-if="user.config.picture"
style="max-width: 100px"
:src="user.config.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-model="user.id"
:label="$t('user_id')"
filled
dense
readonly
:type="showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="showUserId = !showUserId"
dense
flat
:icon="showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="user.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<div v-if="!user.email" class="row"></div>
<div v-if="!user.email" class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS or
"github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm text-h6">
<span v-text="$t('verify_email')"></span>:
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<q-btn
:href="`/api/v1/auth/google?user_id=${user.id}`"
type="a"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
></q-img>
</q-avatar>
<div>Google</div>
</q-btn>
</div>
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<q-btn
:href="`/api/v1/auth/github?user_id=${user.id}`"
type="a"
outline
no-caps
color="grey"
rounded
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
></q-img>
</q-avatar>
<div>GitHub</div>
</q-btn>
</div>
{%endif%}
</div>
</q-card-section>
<q-card-section v-if="user.config">
<q-input
v-model="user.config.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.config.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.config.provider"
:label="$t('auth_provider')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.config.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
</q-tab-panel>
<q-tab-panel name="theme">
<div class="row q-mb-md">
<div class="col-4"><span v-text="$t('language')"></span></div>
<div class="col-8">
<q-btn-dropdown
dense
flat
round
size="sm"
icon="language"
class="q-pl-md"
>
<q-list v-for="(lang, index) in g.langs" :key="index">
<q-item
clickable
v-close-popup
:active="activeLanguage(lang.value)"
@click="changeLanguage(lang.value)"
><q-item-section>
<q-item-label
v-text="lang.display ?? lang.value.toUpperCase()"
></q-item-label>
<q-tooltip
><span v-text="lang.label"></span
></q-tooltip>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('color_scheme')"></span>
</div>
<div class="col-8">
<q-btn
v-if="g.allowedThemes.includes('classic')"
dense
flat
@click="changeColor('classic')"
icon="circle"
color="deep-purple"
size="md"
><q-tooltip>classic</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('bitcoin')"
dense
flat
@click="changeColor('bitcoin')"
icon="circle"
color="orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('mint')"
dense
flat
@click="changeColor('mint')"
icon="circle"
color="green"
size="md"
><q-tooltip>mint</q-tooltip> </q-btn
><q-btn
v-if="g.allowedThemes.includes('autumn')"
dense
flat
@click="changeColor('autumn')"
icon="circle"
color="brown"
size="md"
><q-tooltip>autumn</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('monochrome')"
dense
flat
@click="changeColor('monochrome')"
icon="circle"
color="grey"
size="md"
><q-tooltip>monochrome</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('salvador')"
dense
flat
@click="changeColor('salvador')"
icon="circle"
color="blue-10"
size="md"
><q-tooltip>elSalvador</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('freedom')"
dense
flat
@click="changeColor('freedom')"
icon="circle"
color="pink-13"
size="md"
><q-tooltip>freedom</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('cyber')"
dense
flat
@click="changeColor('cyber')"
icon="circle"
color="light-green-9"
size="md"
><q-tooltip>cyber</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('flamingo')"
dense
flat
@click="changeColor('flamingo')"
icon="circle"
color="pink-3"
size="md"
><q-tooltip>flamingo</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleDarkMode"
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
size="sm"
>
<q-tooltip
><span v-text="$t('toggle_darkmode')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">Notifications</div>
<div class="col-8">
<lnbits-notifications-btn
v-if="g.user"
pubkey="{{ WEBPUSH_PUBKEY }}"
></lnbits-notifications-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('payment_reactions')"></span>
</div>
<div class="col-8">
<q-select
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@input="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
></q-tooltip>
</q-select>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %}
+379 -108
View File
@@ -2,7 +2,7 @@
%} {{ window_vars(user, extensions) }}{% block page %} %} {{ window_vars(user, extensions) }}{% block page %}
<div class="row q-col-gutter-md q-mb-md"> <div class="row q-col-gutter-md q-mb-md">
<div class="col-sm-9 col-xs-12"> <div class="col-sm-9 col-xs-12">
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p> <p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
</div> </div>
<div class="col-sm-3 col-xs-12 q-ml-auto"> <div class="col-sm-3 col-xs-12 q-ml-auto">
@@ -43,9 +43,10 @@
:label="$t('featured')" :label="$t('featured')"
@update="val => tab = val.name" @update="val => tab = val.name"
></q-tab> ></q-tab>
<i v-if="!g.user.admin && tab != 'installed'" <i
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i v-if="!g.user.admin && tab != 'installed'"
> v-text="$t('only_admins_can_install')"
></i>
</q-tabs> </q-tabs>
</div> </div>
</div> </div>
@@ -89,45 +90,42 @@
color="green" color="green"
class="float-right" class="float-right"
> >
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small> <small v-text="$t('new_version')"></small>
<q-tooltip <q-tooltip
><span v-text="extension.latestRelease.version"></span ><span v-text="extension.latestRelease.version"></span
></q-tooltip> ></q-tooltip>
</q-badge> </q-badge>
{% raw %} <div
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"> class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"
{{ extension.name }} v-text="extension.name"
</div> ></div>
<div <div
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md" class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
style="min-height: 60px" style="min-height: 60px"
> v-text="extension.name"
{{ extension.name }} ></div>
</div>
<div <div
class="text-subtitle2 gt-sm" class="text-subtitle2 gt-sm"
style="font-size: 11px; height: 34px" style="font-size: 11px; height: 34px"
> v-text="extension.shortDescription || extension.installedRelease?.description"
{{ extension.shortDescription || ></div>
extension.installedRelease?.description }} <div
</div> class="text-subtitle1 lt-md q-mt-sm q-mb-xs"
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs"> v-text="extension.name"
{{ extension.name }} ></div>
</div>
<div <div
class="text-subtitle2 lt-md" class="text-subtitle2 lt-md"
style="font-size: 9px; height: 34px" style="font-size: 9px; height: 34px"
> v-text="extension.shortDescription"
{{ extension.shortDescription }} ></div>
</div>
{% endraw %}
</div> </div>
</div> </div>
<div class="row q-pt-sm"> <div class="row q-pt-sm">
<div class="col"> <div class="col">
<small v-if="extension.dependencies?.length" <small
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</small v-if="extension.dependencies?.length"
> v-text="$t('extension_depends_on')"
></small>
<small v-else>&nbsp;</small> <small v-else>&nbsp;</small>
<q-badge <q-badge
v-for="dep in extension.dependencies" v-for="dep in extension.dependencies"
@@ -148,20 +146,18 @@
size="1.5em" size="1.5em"
:max="5" :max="5"
color="primary" color="primary"
><q-tooltip ><q-tooltip>
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip <span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating ></q-rating>
>
<q-rating <q-rating
v-model="maxStars" v-model="maxStars"
class="lt-md" class="lt-md"
size="1.5em" size="1.5em"
:max="5" :max="5"
color="primary" color="primary"
><q-tooltip ><q-tooltip>
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip <span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating ></q-rating>
>
<q-toggle <q-toggle
v-if="extension.isAvailable && extension.isInstalled && g.user.admin" v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') " :label="extension.isActive ? $t('activated'): $t('deactivated') "
@@ -169,11 +165,11 @@
style="max-height: 21px" style="max-height: 21px"
v-model="extension.isActive" v-model="extension.isActive"
@input="toggleExtension(extension)" @input="toggleExtension(extension)"
><q-tooltip ><q-tooltip>
>{%raw%}{{ $t('activate_extension_details') <span
}}{%endraw%}</q-tooltip v-text="$t('activate_extension_details')"
></q-toggle ></span> </q-tooltip
> ></q-toggle>
</div> </div>
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
@@ -185,9 +181,9 @@
flat flat
color="primary" color="primary"
type="a" type="a"
:href="[extension.id, '/?usr=', g.user.id].join('')" :href="extension.id + '/'"
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn :label="$t('open')"
> ></q-btn>
<q-btn <q-btn
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled" v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat flat
@@ -195,12 +191,13 @@
type="a" type="a"
:href="['{{ :href="['{{
url_for('install.extensions') url_for('install.extensions')
}}', '?usr=', g.user.id, '&disable=', extension.id].join('')" }}', '?disable=', extension.id].join('')"
:label="$t('disable')"
></q-btn>
<q-badge
v-if="extension.isAdminOnly && !user.admin"
v-text="$t('admin_only')"
> >
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
>
<q-badge v-if="extension.isAdminOnly && !user.admin">
{%raw%}{{ $t('admin_only') }}{%endraw%}
</q-badge> </q-badge>
<q-btn <q-btn
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)" v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
@@ -209,9 +206,9 @@
type="a" type="a"
:href="['{{ :href="['{{
url_for('install.extensions') url_for('install.extensions')
}}', '?usr=', g.user.id, '&enable=', extension.id].join('')" }}', '?enable=', extension.id].join('')"
:label="$t('enable')"
> >
{%raw%}{{ $t('enable') }}{%endraw%}
<q-tooltip> <q-tooltip>
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
</span> </q-tooltip </span> </q-tooltip
@@ -222,12 +219,11 @@
flat flat
color="primary" color="primary"
v-if="g.user.admin" v-if="g.user.admin"
> :label="$t('manage')"
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip ><q-tooltip
>{%raw%}{{ $t('manage_extension_details') ><span v-text="$t('manage_extension_details')"></span
}}{%endraw%}</q-tooltip ></q-tooltip>
></q-btn </q-btn>
>
</div> </div>
<div v-else> <div v-else>
<q-spinner color="primary" size="2.55em"></q-spinner> <q-spinner color="primary" size="2.55em"></q-spinner>
@@ -240,7 +236,7 @@
class="float-right" class="float-right"
> >
<q-badge> <q-badge>
{% raw %}{{ extension.installedRelease.version }}{% endraw %} <span v-text="extension.installedRelease.version"></span>
<q-tooltip> <q-tooltip>
<span v-text="$t('extension_installed_version')"></span> <span v-text="$t('extension_installed_version')"></span>
</q-tooltip> </q-tooltip>
@@ -252,10 +248,10 @@
</div> </div>
<q-dialog v-model="showUninstallDialog"> <q-dialog v-model="showUninstallDialog">
<q-card class="q-pa-lg"> <q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6> <h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p> <p>
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br /> <span v-text="$t('extension_uninstall_warning')"></span><br />
{%raw%}{{ $t('confirm_continue') }}{%endraw%} <span v-text="$t('confirm_continue')"></span>
</p> </p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
@@ -264,32 +260,39 @@
value="false" value="false"
label="Cleanup database tables" label="Cleanup database tables"
> >
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"> <q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%} ><span v-text="$t('extension_db_drop_info')"></span>
</q-tooltip> </q-tooltip>
</q-checkbox> </q-checkbox>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn outline color="grey" @click="uninstallExtension()" <q-btn
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn outline
> color="grey"
<q-btn v-close-popup flat color="grey" class="q-ml-auto" @click="uninstallExtension()"
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn v-text="$t('uninstall_confirm')"
> ></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showDropDbDialog"> <q-dialog v-model="showDropDbDialog">
<q-card v-if="selectedExtension" class="q-pa-lg"> <q-card v-if="selectedExtension" class="q-pa-lg">
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6> <h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p> <p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
<q-input <q-input
v-model="dropDbExtensionId" v-model="dropDbExtensionId"
:label="selectedExtension.id" :label="selectedExtension.id"
></q-input> ></q-input>
<br /> <br />
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p> <p v-text="$t('confirm_continue')"></p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@@ -297,17 +300,58 @@
outline outline
color="red" color="red"
@click="dropExtensionDb()" @click="dropExtensionDb()"
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn v-text="$t('confirm')"
> ></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn v-close-popup
> flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showUpgradeDialog"> <q-dialog v-model="showUpgradeDialog">
<q-card class="q-pa-lg lnbits__dialog-card"> <q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<a :href="'lightning:' + selectedRelease.paymentRequest">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div v-else>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</q-card-section>
<div class="row q-mt-lg">
<div class="col">
<q-btn
v-if="selectedRelease.paymentRequest"
outline
color="grey"
@click="copyText(selectedRelease.paymentRequest)"
:label="$t('copy_invoice')"
></q-btn>
</div>
<div class="col">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-card-section> <q-card-section>
<div class="text-h6" v-text="selectedExtension?.name"></div> <div class="text-h6" v-text="selectedExtension?.name"></div>
</q-card-section> </q-card-section>
@@ -337,7 +381,7 @@
<q-item-section> <q-item-section>
<div class="row"> <div class="row">
<div class="col-10"> <div class="col-10">
{%raw%}{{ $t('repository') }}{%endraw%} <span v-text="$t('repository')"></span>
<br /> <br />
<small v-text="repoName"></small> <small v-text="repoName"></small>
<q-tooltip <q-tooltip
@@ -376,19 +420,104 @@
color="pink" color="pink"
size="70px" size="70px"
></q-icon> ></q-icon>
Cannot get the release details. <span v-text="$t('release_details_error')"></span>
</div> </div>
<q-card v-else> <q-card v-else>
<q-card-section v-if="release.is_version_compatible"> <q-card-section v-if="release.is_version_compatible">
<q-btn <span
v-if="!release.isInstalled" v-if="release.requiresPayment && !release.paid_sats"
@click="installExtension(release)" v-text="$t('extension_cost', {cost: release.cost_sats})"
color="primary unelevated mt-lg pt-lg" class="q-mb-lg"
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn ></span>
> <span
<q-btn v-else @click="showUninstall()" flat color="red"> v-if="release.requiresPayment && release.paid_sats"
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn class="q-mb-lg"
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
></span>
<div
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
> >
<q-btn
v-if="!release.isInstalled"
@click="installExtension(release)"
color="primary unelevated mt-lg pt-lg"
:label="$t('install')"
></q-btn>
</div>
<div v-if="release.requiresPayment && !release.paid_sats">
<div v-if="!release.payment_hash">
<q-input
filled
dense
v-model.number="release.paidAmount"
type="number"
:min="release.cost_sats"
suffix="sat"
class="q-mt-sm"
>
</q-input>
<q-select
filled
dense
emit-value
v-model="release.wallet"
:options="g.user.walletOptions"
label="Wallet *"
class="q-mt-sm"
>
</q-select>
<q-btn
unelevated
color="primary"
@click="payAndInstall(release)"
:disabled="!release.wallet"
class="q-mt-sm"
:label="$t('pay_from_wallet')"
></q-btn>
<q-btn
unelevated
color="primary"
@click="showQRCode(release)"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
</div>
<div v-else>
<br />
<span
class="q-mb-lg q-mt-lg"
v-text="'There is a previous pending invoice for this release.'"
></span>
<br />
<q-btn
unelevated
@click="installExtension(release)"
color="primary"
class="q-mt-sm"
:label="$t('retry_install')"
></q-btn>
<q-btn
unelevated
@click="clearHangingInvoice(release)"
color="primary"
class="q-mt-sm float-right"
:label="$t('new_payment')"
></q-btn>
</div>
</div>
<div>
<q-btn
v-if="release.isInstalled"
@click="showUninstall()"
:label="$t('uninstall')"
flat
color="red"
></q-btn>
</div>
<a <a
v-if="release.html_url" v-if="release.html_url"
class="text-secondary float-right" class="text-secondary float-right"
@@ -396,11 +525,11 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style="color: inherit" style="color: inherit"
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a v-text="$t('release_notes')"
> ></a>
</q-card-section> </q-card-section>
<q-card-section v-else> <q-card-section v-else>
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%} <span v-text="$t('extension_min_lnbits_version')"></span>
<strong> <strong>
<span v-text="release.min_lnbits_version"></span> <span v-text="release.min_lnbits_version"></span>
</strong> </strong>
@@ -408,8 +537,11 @@
<q-card v-if="release.warning"> <q-card v-if="release.warning">
<q-card-section> <q-card-section>
<div class="text-h6"> <div class="text-h6">
<q-badge color="yellow" text-color="black"> <q-badge
{%raw%}{{ $t('warning') }}{%endraw%} color="yellow"
text-color="black"
v-text="$t('warning')"
>
</q-badge> </q-badge>
</div> </div>
<div class="text-subtitle2"> <div class="text-subtitle2">
@@ -432,9 +564,8 @@
@click="showUninstall()" @click="showUninstall()"
flat flat
color="red" color="red"
> v-text="$t('uninstall')"
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn ></q-btn>
>
<q-btn <q-btn
v-else-if="selectedExtension?.hasDatabaseTables" v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()" @click="showDropDb()"
@@ -442,9 +573,13 @@
color="red" color="red"
:label="$t('drop_db')" :label="$t('drop_db')"
></q-btn> ></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"> <q-btn
{%raw%}{{ $t('close') }}{%endraw%}</q-btn v-close-popup
> flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
@@ -464,8 +599,10 @@
dropDbExtensionId: '', dropDbExtensionId: '',
selectedExtension: null, selectedExtension: null,
selectedExtensionRepos: null, selectedExtensionRepos: null,
selectedRelease: null,
uninstallAndDropDb: false, uninstallAndDropDb: false,
maxStars: 5, maxStars: 5,
paylinkWebsocket: null,
user: null user: null
} }
}, },
@@ -504,19 +641,29 @@
.filter(extensionNameContains(term)) .filter(extensionNameContains(term))
this.tab = tab this.tab = tab
}, },
installExtension: async function (release) { installExtension: async function (release) {
// no longer required to check if the invoice was paid
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const extension = this.selectedExtension const extension = this.selectedExtension
extension.inProgress = true extension.inProgress = true
this.showUpgradeDialog = false this.showUpgradeDialog = false
release.payment_hash =
release.payment_hash || this.getPaylinkHash(release.pay_link)
LNbits.api LNbits.api
.request( .request(
'POST', 'POST',
`/api/v1/extension?usr=${this.g.user.id}`, `/api/v1/extension`,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
ext_id: extension.id, ext_id: extension.id,
archive: release.archive, archive: release.archive,
source_repo: release.source_repo source_repo: release.source_repo,
payment_hash: release.payment_hash,
version: release.version
} }
) )
.then(response => { .then(response => {
@@ -530,8 +677,9 @@
this.tab = 'installed' this.tab = 'installed'
}) })
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(err) console.warn(err)
extension.inProgress = false extension.inProgress = false
LNbits.utils.notifyApiError(err)
}) })
}, },
uninstallExtension: async function () { uninstallExtension: async function () {
@@ -542,7 +690,7 @@
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
`/api/v1/extension/${extension.id}?usr=${this.g.user.id}`, `/api/v1/extension/${extension.id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(response => { .then(response => {
@@ -576,7 +724,7 @@
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`, `/api/v1/extension/${extension.id}/db`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(response => { .then(response => {
@@ -598,9 +746,8 @@
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
"{{ url_for('install.extensions') }}?usr=" + "{{ url_for('install.extensions') }}" +
this.g.user.id + '?' +
'&' +
action + action +
'=' + '=' +
extension.id extension.id
@@ -624,12 +771,14 @@
showUpgrade: async function (extension) { showUpgrade: async function (extension) {
this.selectedExtension = extension this.selectedExtension = extension
this.showUpgradeDialog = true this.selectedRelease = null
this.selectedExtensionRepos = null this.selectedExtensionRepos = null
this.showUpgradeDialog = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
`/api/v1/extension/${extension.id}/releases?usr=${this.g.user.id}`, `/api/v1/extension/${extension.id}/releases`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@@ -649,6 +798,12 @@
if (release.isInstalled) { if (release.isInstalled) {
repos[release.source_repo].isInstalled = true repos[release.source_repo].isInstalled = true
} }
if (release.pay_link) {
release.requiresPayment = true
release.paidAmount = release.cost_sats
release.payment_hash = this.getPaylinkHash(release.pay_link)
}
repos[release.source_repo].releases.push(release) repos[release.source_repo].releases.push(release)
return repos return repos
}, {}) }, {})
@@ -657,6 +812,122 @@
extension.inProgress = false extension.inProgress = false
} }
}, },
async payAndInstall(release) {
try {
this.selectedExtension.inProgress = true
this.showUpgradeDialog = false
const paymentInfo = await this.requestPayment(
this.selectedExtension.id,
release
)
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
const {data} = await LNbits.api.payInvoice(
wallet,
paymentInfo.payment_request
)
release.payment_hash = data.payment_hash
await this.installExtension(release)
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
} finally {
this.selectedExtension.inProgress = false
}
},
async showQRCode(release) {
this.selectedRelease = release
try {
const data = await this.requestPayment(
this.selectedExtension.id,
release
)
this.selectedRelease.paymentRequest = data.payment_request
this.selectedRelease.payment_hash = data.payment_hash
this.selectedRelease = _.clone(this.selectedRelease)
this.rememberPaylinkHash(
this.selectedRelease.pay_link,
this.selectedRelease.payment_hash
)
this.subscribeToPaylinkWs(
this.selectedRelease.pay_link,
data.payment_hash
)
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
}
},
async requestPayment(extId, release) {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/invoice`,
this.g.user.wallets[0].adminkey,
{
ext_id: extId,
archive: release.archive,
source_repo: release.source_repo,
cost_sats: release.paidAmount,
version: release.version
}
)
return data
},
clearHangingInvoice(release) {
this.forgetPaylinkHash(release.pay_link)
release.payment_hash = null
},
rememberPaylinkHash(pay_link, payment_hash) {
this.$q.localStorage.set(
`lnbits.extensions.paylink.${pay_link}`,
payment_hash
)
},
getPaylinkHash(pay_link) {
return this.$q.localStorage.getItem(
`lnbits.extensions.paylink.${pay_link}`
)
},
forgetPaylinkHash(pay_link) {
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
},
subscribeToPaylinkWs(pay_link, payment_hash) {
const url = new URL(`${pay_link}/${payment_hash}`)
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
this.paylinkWebsocket = new WebSocket(url)
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
const resp = JSON.parse(data)
if (resp.paid) {
this.$q.notify({
type: 'positive',
message: 'Invoice Paid!'
})
this.installExtension(this.selectedRelease)
} else {
this.$q.notify({
type: 'warning',
message: 'Invoice tracking lost!'
})
}
})
},
unsubscribeFromPaylinkWs() {
try {
this.paylinkWebsocket && this.paylinkWebsocket.close()
} catch (error) {
console.warn(error)
}
},
hasNewVersion: function (extension) { hasNewVersion: function (extension) {
if (extension.installedRelease && extension.latestRelease) { if (extension.installedRelease && extension.latestRelease) {
return ( return (
@@ -694,7 +965,7 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
`/api/v1/extension/release/${org}/${repo}/${release.version}?usr=${this.g.user.id}`, `/api/v1/extension/release/${org}/${repo}/${release.version}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
release.loaded = true release.loaded = true
@@ -0,0 +1,139 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong>Welcome to LNbits</strong>
<p>Set up the Superuser account below.</p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
})
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}
+358 -84
View File
@@ -2,77 +2,342 @@
<script src="{{ static_url_for('static', 'js/index.js') }}"></script> <script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page %} {% endblock %} {% block page %}
<div class="row q-col-gutter-md justify-center"> <div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md"> <div
{% if lnurl or LNBITS_NEW_ACCOUNTS_ALLOWED %} v-if="isUserAuthorized"
<q-card> class="col-12 col-md-6 col-lg-6 q-gutter-y-md"
<q-card-section> ></div>
{% if lnurl %} <div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
<q-btn <div class="gt-sm">
unelevated <h3 class="q-my-none" v-if="'{{SITE_TITLE}}' == 'LNbits'">
color="primary" {{SITE_TITLE}}
@click="processing" </h3>
type="a" <h5 class="q-my-md" v-if="'{{SITE_TITLE}}' == 'LNbits'">
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}" {{SITE_TAGLINE}}
v-text="$t('press_to_claim')" </h5>
></q-btn> </div>
{% elif LNBITS_NEW_ACCOUNTS_ALLOWED %} {% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
<q-form @submit="createWallet" class="q-gutter-md"> LNBITS_AUTH_METHODS)%}
<q-input <div class="row q-mt-xl">
filled <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
dense <q-card>
v-model="walletName" <q-card-section>
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })' <q-btn
></q-input> unelevated
<q-btn color="primary"
unelevated @click="processing"
color="primary" type="a"
:disable="walletName == ''" href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
type="submit" v-text="$t('press_to_claim')"
:label="$t('add_wallet')" class="full-width"
></q-btn> ></q-btn>
</q-form> </q-card-section>
{% endif %} </q-card>
</q-card-section> </div>
</q-card> </div>
{% endif %} {%else%} {% endif %}
<q-card> <div class="row q-mt-md">
<q-card-section> <div class="col-12 col-md-10 col-lg-10 q-gutter-y-md">
<h3 class="q-my-none">{{SITE_TITLE}}</h3> <q-badge v-if="isAccessTokenExpired" color="primary" rounded>
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5> <div class="text-h5">
<div v-if="'{{SITE_TITLE}}' == 'LNbits'"> <span v-text="$t('session_has_expired')"></span>
<p v-text="$t('lnbits_description')"></p>
<div class="row q-mt-md q-gutter-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
></q-btn>
</div> </div>
</div> </q-badge>
<div v-else> <q-card class="shadow-12">
<div v-html="formatDescription"></div> {% if "user-id-only" in LNBITS_AUTH_METHODS %}
</div> <q-card-section>
</q-card-section> <div class="text-h6">
</q-card> <span v-text="$t('instant_access_question')"></span>
<br />
<q-badge
@click="showLogin('user-id-only')"
color="primary"
class="cursor-pointer"
rounded
>
<strong>
<q-icon name="account_circle" size="xs"></q-icon>
<span v-text="$t('login_with_user_id')"></span> </strong
></q-badge>
{% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
<span v-text="$t('or')"></span>
<q-badge
@click="showRegister('user-id-only')"
color="primary"
class="cursor-pointer"
rounded
>
<strong>
<q-icon name="add" size="xs"></q-icon>
<span v-text="$t('create_new_wallet')"></span>
</strong>
</q-badge>
{%endif%}
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section
v-if="authAction === 'login' && authMethod === 'user-id-only'"
>
<b> <span v-text="$t('login_with_user_id')"></span> </b><br /><br />
<q-form @submit="loginUsr" class="q-gutter-md">
<q-input
filled
dense
v-model="usr"
label="usr"
type="password"
></q-input>
<div>
<q-btn
unelevated
color="primary"
:disable="usr == ''"
type="submit"
label="Login"
class="full-width"
></q-btn>
</div>
</q-form>
</q-card-section>
{%endif%} {% if "username-password" in LNBITS_AUTH_METHODS %}
<q-card-section
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div class="q-mb-lg">
<strong>
<span v-text="$t('login_to_account')"></span>
</strong>
</div>
<q-form @submit="login" class="q-gutter-md">
<q-input
filled
dense
v-model="username"
name="username"
:label="$t('username_or_email') + ' *'"
></q-input>
<q-input
filled
dense
v-model="password"
name="password"
:label="$t('password') + ' *'"
type="password"
></q-input>
<div>
<q-btn
:disable="!username || !password"
color="primary"
type="submit"
:label="$t('login')"
class="full-width"
></q-btn>
</div>
</q-form>
</q-card-section>
<q-card-section
v-if="authAction === 'register' && authMethod === 'username-password'"
>
<b> <span v-text="$t('create_account')"></span> </b><br /><br />
<q-form @submit="register" class="q-gutter-md">
<q-input
filled
dense
required
v-model="username"
:label="$t('username') + ' *'"
:rules="[(val) => validateUsername(val) || $t('invalid_username')]"
></q-input>
<q-input
filled
dense
v-model="password"
:label="$t('password') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
filled
dense
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<div>
<q-btn
unelevated
color="primary"
:disable="!password || !passwordRepeat|| !username || (password !== passwordRepeat)"
type="submit"
class="full-width"
:label="$t('create_account')"
></q-btn>
</div>
</q-form>
</q-card-section>
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
<q-card-section
v-if="authAction === 'register' && authMethod === 'user-id-only'"
>
<div>
<q-form @submit="createWallet" class="q-gutter-md">
<q-input
filled
dense
v-model="walletName"
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
></q-input>
<div>
<q-btn
color="primary"
:disable="walletName == ''"
type="submit"
:label="$t('add_wallet')"
class="full-width"
></q-btn>
</div>
</q-form>
</div>
</q-card-section>
{% if "username-password" in LNBITS_AUTH_METHODS %}
<q-card-section
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div>
<q-btn
color="grey"
outline
:label="$t('register')"
class="full-width"
@click="showRegister('username-password')"
></q-btn>
</div>
</q-card-section>
{%endif%}
<q-separator></q-separator>
{% endif %}
<q-card-section
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/google"
type="a"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
></q-img>
</q-avatar>
<div>
<span v-text="$t('signin_with_google')"></span>
</div>
</q-btn>
</div>
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/github"
type="a"
outline
no-caps
color="grey"
rounded
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
></q-img>
</q-avatar>
<div><span v-text="$t('signin_with_github')"></span></div>
</q-btn>
</div>
{%endif%} {% if "keycloak-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/keycloak"
type="a"
outline
no-caps
color="grey"
rounded
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/keycloak-logo.png') }}'"
></q-img>
</q-avatar>
<div><span v-text="$t('signin_with_keycloak')"></span></div>
</q-btn>
</div>
{%endif%}
</div>
</q-card-section>
<q-card-section v-else>
<div class="row">
<div class="col q-pa-sm">
<q-btn
@click="showLogin('username-password')"
:label="$t('back')"
outline
rounded
color="grey"
class="full-width"
>
</q-btn>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
</div> </div>
<!-- Ads --> <div
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'"> v-if="'{{SITE_TITLE}}' != 'LNbits'"
class="col-12 col-md-5 col-lg-5 q-pt-xl"
>
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
<div v-html="formatDescription"></div>
</div>
<div class="col-12 col-md-3 col-lg-3 gt-sm" v-else>
<div class="row q-col-gutter-lg justify-center"> <div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class="full-width"
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class="full-width q-mb-lg"
></q-btn>
</div>
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm"> <div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn <q-btn
flat flat
@@ -225,27 +490,36 @@
></q-img> ></q-img>
</a> </a>
</div> </div>
<div class="col q-pl-md"></div> <div class="col q-pl-md">
<a href="https://zbd.gg" target="_blank" rel="noopener noreferrer">
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
</div> </div>
</div> </div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_SPACE_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 90%"
/>
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
</a>
</div>
{% endfor %} {% endif %}
</div> </div>
</div> </div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_SPACE_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md">
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" />
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
</a>
</div>
{% endfor %} {% endif %}
</div>
<div v-if="'{{SITE_TITLE}}' == 'LNbits'" class="row gt-sm q-mt-xl">
<div class="col-1"></div>
<div class="col-10 q-pl-xl">
<span v-text="$t('lnbits_description')"></span>
</div>
<div class="col-1"></div>
</div> </div>
{% endblock %} {% endblock %}
+187 -135
View File
@@ -15,52 +15,57 @@
{% elif HIDE_API %} {% elif HIDE_API %}
<div class="col-12 q-gutter-y-md"> <div class="col-12 q-gutter-y-md">
{% else %} {% else %}
<div class="col-12 col-md-7 q-gutter-y-md"> <div
class="col-12 col-md-7 q-gutter-y-md"
:style="$q.screen.lt.md ? {
position: mobileSimple ? 'fixed !important': ''
, top: mobileSimple ? '50% !important': ''
, left: mobileSimple ? '50% !important': ''
, transform: mobileSimple ? 'translate(-50%, -50%) !important': ''
} : ''"
>
{% endif %} {% endif %}
<q-card> <q-card
:style="$q.screen.lt.md ? {
background: $q.screen.lt.md ? 'none !important': ''
, boxShadow: $q.screen.lt.md ? 'none !important': ''
, margin: $q.screen.lt.md && mobileSimple ? 'auto !important': ''
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
} : ''"
>
<q-card-section> <q-card-section>
<h3 class="q-my-none"> <h3 class="q-my-none text-no-wrap">
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong> <strong v-text="formattedBalance"></strong>
{{LNBITS_DENOMINATION}} <small>{{LNBITS_DENOMINATION}}</small>
<q-btn <lnbits-update-balance
v-if="'{{user.super_user}}' == 'True'" v-if="'{{user.super_user}}' == 'True'"
:wallet_id="this.g.wallet.id"
flat flat
:callback="updateBalanceCallback"
round round
color="primary" />
icon="add"
size="md"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
filled
:label='$t("credit_label", { denomination: "{{LNBITS_DENOMINATION}}"})'
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
:mask="'{{LNBITS_DENOMINATION}}' != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="'{{LNBITS_DENOMINATION}}' != 'sats' ? '0.01' : '1'"
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
</q-btn>
</h3> </h3>
<div v-if="g.wallet.currency"> <div class="row">
<span <div class="col">
class="text-h5 text-italic" <div v-if="g.wallet.currency">
v-text="formattedFiatBalance" <span
style="opacity: 0.75" class="text-h5 text-italic"
></span> v-text="formattedFiatBalance"
style="opacity: 0.75"
></span>
</div>
</div>
<div class="col">
<q-btn
@click="mobileSimple = !mobileSimple"
color="primary"
class="float-right lt-md"
size="sm"
flat
:icon="mobileSimple ? 'unfold_more' : 'unfold_less'"
:label="mobileSimple ? $t('more') : $t('less')"
></q-btn>
</div>
</div> </div>
</q-card-section> </q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm"> <div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
@@ -98,7 +103,13 @@
</div> </div>
</q-card> </q-card>
<q-card> <q-card
:style="$q.screen.lt.md ? {
background: $q.screen.lt.md ? 'none !important': ''
, boxShadow: $q.screen.lt.md ? 'none !important': ''
, marginTop: $q.screen.lt.md ? '0px !important': ''
} : ''"
>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-sm"> <div class="row items-center no-wrap q-mb-sm">
<div class="col"> <div class="col">
@@ -107,16 +118,13 @@
:v-text="$t('transactions')" :v-text="$t('transactions')"
></h5> ></h5>
</div> </div>
<div class="col-auto"> <div class="gt-sm col-auto">
<q-btn <q-btn
flat flat
color="grey" color="grey"
@click="exportCSV" @click="exportCSV"
:label="$t('export_csv')" :label="$t('export_csv')"
></q-btn> ></q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn <q-btn
dense dense
flat flat
@@ -132,10 +140,13 @@
</div> </div>
</div> </div>
<q-input <q-input
:style="$q.screen.lt.md ? {
display: mobileSimple ? 'none !important': ''
} : ''"
filled filled
dense dense
clearable clearable
v-model="paymentsTable.filter" v-model="paymentsTable.search"
debounce="300" debounce="300"
:placeholder="$t('search_by_tag_memo_amount')" :placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md" class="q-mb-md"
@@ -144,22 +155,26 @@
<q-table <q-table
dense dense
flat flat
:data="payments" :data="paymentsOmitter"
:row-key="paymentTableRowKey" :row-key="paymentTableRowKey"
:columns="paymentsTable.columns" :columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination" :pagination.sync="paymentsTable.pagination"
:no-data-label="$t('no_transactions')" :no-data-label="$t('no_transactions')"
:filter="paymentsTable.filter" :filter="paymentsTable.search"
:loading="paymentsTable.loading" :loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
@request="fetchPayments" @request="fetchPayments"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" <q-th
>{{ col.label }}</q-th v-for="col in props.cols"
> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
@@ -178,7 +193,9 @@
color="grey" color="grey"
@click="props.expand = !props.expand" @click="props.expand = !props.expand"
> >
<q-tooltip>{{$t('pending')}}</q-tooltip> <q-tooltip
><span v-text="$t('pending')"></span
></q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
<q-td <q-td
@@ -192,42 +209,42 @@
text-color="black" text-color="black"
> >
<a <a
v-text="'#'+props.row.tag"
class="inherit" class="inherit"
:href="['/', props.row.tag, '/?usr=', user.id].join('')" :href="['/', props.row.tag].join('')"
> ></a>
#{{ props.row.tag }}
</a>
</q-badge> </q-badge>
{{ props.row.memo }} <span v-text="props.row.memo"></span>
<br /> <br />
<i> <i>
{{ props.row.dateFrom }}<q-tooltip <span v-text="props.row.dateFrom"></span>
>{{ props.row.date }}</q-tooltip <q-tooltip
></i ><span v-text="props.row.date"></span
> ></q-tooltip>
</i>
</q-td> </q-td>
{% endraw %}
<q-td <q-td
auto-width auto-width
key="amount" key="amount"
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
:props="props" :props="props"
>{% raw %} {{ v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100 >
}}
</q-td> </q-td>
<q-td auto-width key="amount" v-else :props="props"> <q-td auto-width key="amount" v-else :props="props">
{{ props.row.fsat }}<br /> <span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency"> <i v-if="props.row.extra.wallet_fiat_currency">
{{ formatFiat(props.row.extra.wallet_fiat_currency, <span
props.row.extra.wallet_fiat_amount) }} v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
></span>
<br /> <br />
</i> </i>
<i v-if="props.row.extra.fiat_currency"> <i v-if="props.row.extra.fiat_currency">
{{ formatFiat(props.row.extra.fiat_currency, <span
props.row.extra.fiat_amount) }} v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
></span>
</i> </i>
</q-td> </q-td>
</q-tr> </q-tr>
@@ -302,30 +319,28 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
{% if HIDE_API %} {% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md"> <div class="col-12 col-md-4 q-gutter-y-md">
{% else %} {% else %}
<div class="col-12 col-md-5 q-gutter-y-md"> <div
v-if="!mobileSimple || $q.screen.gt.sm"
class="col-12 col-md-5 q-gutter-y-md"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm"> <h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet: {{ SITE_TITLE }} Wallet:
<strong><em>{{ wallet.name }}</em></strong> <strong><em>{{wallet.name}}</em></strong>
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-list> <q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %} {% if wallet.lnurlwithdraw_full %}
<q-expansion-item <q-expansion-item
group="extras" group="extras"
@@ -355,7 +370,8 @@
<q-card-section class="text-center"> <q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p> <p v-text="$t('export_to_phone_desc')"></p>
<qrcode <qrcode
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'" :value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode> ></qrcode>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -437,6 +453,9 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator></q-separator>
{% include "core/_api_docs.html" %}
</q-list> </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -470,16 +489,15 @@
</div> </div>
<q-dialog v-model="receive.show" position="top"> <q-dialog v-model="receive.show" position="top">
{% raw %}
<q-card <q-card
v-if="!receive.paymentReq" v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<q-form @submit="createInvoice" class="q-gutter-md"> <q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none"> <p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice: <b v-text="receive.lnurl.domain"></b> is requesting an invoice:
</p> </p>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input <q-input
filled filled
dense dense
@@ -523,7 +541,6 @@
v-model.trim="receive.data.memo" v-model.trim="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
></q-input> ></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@@ -531,9 +548,10 @@
:disable="receive.data.amount == null || receive.data.amount <= 0" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit" type="submit"
> >
<span v-if="receive.lnurl"> <span
{{$t('withdraw_from')}} {{receive.lnurl.domain}} v-if="receive.lnurl"
</span> v-text="$t('withdraw_from') + receive.lnurl.domain"
></span>
<span v-else v-text="$t('create_invoice')"></span> <span v-else v-text="$t('create_invoice')"></span>
</q-btn> </q-btn>
<q-btn <q-btn
@@ -551,7 +569,10 @@
></q-spinner> ></q-spinner>
</q-form> </q-form>
</q-card> </q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card
v-else-if="receive.paymentReq && receive.lnurl == null"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq"> <a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
@@ -577,28 +598,32 @@
></q-btn> ></q-btn>
</div> </div>
</q-card> </q-card>
{% endraw %}
</q-dialog> </q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top"> <q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice"> <div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none"> <h6
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} class="q-my-none"
</h6> v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
<h6 v-else class="q-my-none"> ></h6>
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% <h6
raw %} v-else
</h6> class="q-my-none"
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
></h6>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<p class="text-wrap"> <p class="text-wrap">
<strong v-text="$t('memo')">:</strong> {{ <strong v-text="$t('memo') + ': '"></strong>
parse.invoice.description }}<br /> <span v-text="parse.invoice.description"></span>
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br /> <br />
<strong>Hash:</strong> {{ parse.invoice.hash }} <strong>Expire date: </strong>
<span v-text="parse.invoice.expireDate"></span>
<br />
<strong>Hash: </strong>
<span v-text="parse.invoice.hash"></span>
</p> </p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg"> <div v-if="canPay" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@@ -632,24 +657,31 @@
</div> </div>
</div> </div>
<div v-else-if="parse.lnurlauth"> <div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md"> <q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6"> <p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>? Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
</p> </p>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<p> <p>
For every website and for every LNbits wallet, a new keypair For every website and for every LNbits wallet, a new keypair
will be deterministically generated so your identity can't be will be deterministically generated so your identity can't be
tied to your LNbits wallet or linked across websites. No other tied to your LNbits wallet or linked across websites. No other
data will be shared with {{ parse.lnurlauth.domain }}. data will be shared with
<span v-text="parse.lnurlauth.domain"></span>.
</p>
<p>
Your public key for <b v-text="parse.lnurlauth.domain"></b> is:
</p> </p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl"> <p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code> <code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
</p> </p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn> <q-btn
unelevated
color="primary"
type="submit"
:label="$t('login')"
></q-btn>
<q-btn <q-btn
:label="$t('cancel')" :label="$t('cancel')"
v-close-popup v-close-popup
@@ -659,55 +691,74 @@
></q-btn> ></q-btn>
</div> </div>
</q-form> </q-form>
{% endraw %}
</div> </div>
<div v-else-if="parse.lnurlpay"> <div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md"> <q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6"> <p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{ <b v-text="parse.lnurlpay.domain"></b> is requesting
parse.lnurlpay.maxSendable | msatoshiFormat }} <span
{{LNBITS_DENOMINATION}} v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
></span>
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0"> <span v-if="parse.lnurlpay.commentAllowed > 0">
<br /> <br />
and a {{parse.lnurlpay.commentAllowed}}-char comment and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
comment
</span> </span>
</p> </p>
<p v-else class="q-my-none text-h6 text-center"> <p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> <b
v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"
></b>
is requesting <br /> is requesting <br />
between between
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and <b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> <b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %} <span v-text="'{{LNBITS_DENOMINATION}}'"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0"> <span v-if="parse.lnurlpay.commentAllowed > 0">
<br /> <br />
and a {{parse.lnurlpay.commentAllowed}}-char comment and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
comment
</span> </span>
</p> </p>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<div class="row"> <div class="row">
<p class="col text-justify text-italic"> <p
{{ parse.lnurlpay.description }} class="col text-justify text-italic"
</p> v-text="parse.lnurlpay.description"
></p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image"> <p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" /> <q-img :src="parse.lnurlpay.image" />
</p> </p>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% endraw %} <q-select
filled
dense
v-if="!parse.lnurlpay.fixed"
v-model="parse.data.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></q-select>
<br />
<q-input <q-input
ref="setAmount"
filled filled
dense dense
v-model.number="parse.data.amount" v-model.number="parse.data.amount"
type="number" :label="$t('amount') + ' (' + parse.data.unit + ') *'"
label="Amount ({{LNBITS_DENOMINATION}}) *" :mask="parse.data.unit != 'sat' ? '#.##' : '#'"
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
fill-mask="0"
reverse-fill-mask
:min="parse.lnurlpay.minSendable / 1000" :min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000" :max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed" :readonly="parse.lnurlpay && parse.lnurlpay.fixed"
></q-input> ></q-input>
{% raw %}
</div> </div>
<div <div
class="col-8 q-pl-md" class="col-8 q-pl-md"
@@ -724,9 +775,7 @@
</div> </div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" <q-btn unelevated color="primary" type="submit">Send</q-btn>
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn <q-btn
:label="$t('cancel')" :label="$t('cancel')"
v-close-popup v-close-popup
@@ -736,7 +785,6 @@
></q-btn> ></q-btn>
</div> </div>
</q-form> </q-form>
{% endraw %}
</div> </div>
<div v-else> <div v-else>
<q-form <q-form
@@ -874,14 +922,18 @@
<q-dialog v-model="disclaimerDialog.show" position="top"> <q-dialog v-model="disclaimerDialog.show" position="top">
<q-card class="q-pa-lg"> <q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6> <h6
<p v-text="$t('disclaimer_dialog')"></p> class="q-my-md text-primary"
v-text="$t('disclaimer_dialog_title')"
></h6>
<p class="whitespace-pre-line" v-text="$t('disclaimer_dialog')"></p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(disclaimerDialog.location.href)" type="a"
:label="$t('copy_wallet_url')" href="/account"
:label="$t('my_account')"
></q-btn> ></q-btn>
<q-btn <q-btn
v-close-popup v-close-popup
+15 -19
View File
@@ -150,7 +150,6 @@
Open channel Open channel
</q-btn> </q-btn>
</div> </div>
{% raw %}
<div> <div>
<div class="text-subtitle1 col-grow">Total</div> <div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance <lnbits-channel-balance
@@ -172,11 +171,12 @@
<q-tr :props="props"> <q-tr :props="props">
<div class="q-pb-sm"> <div class="q-pb-sm">
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<div class="text-subtitle1 col-grow"> <div
{{props.row.name}} class="text-subtitle1 col-grow"
</div> v-text="props.row.name"
></div>
<div class="text-caption" v-if="props.row.short_id"> <div class="text-caption" v-if="props.row.short_id">
{{ props.row.short_id }} <span v-text="props.row.short_id"></span>
<q-btn <q-btn
size="xs" size="xs"
flat flat
@@ -188,9 +188,8 @@
<q-badge <q-badge
rounded rounded
:color="states.find(s => s.value == props.row.state)?.color" :color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
> >
{{ states.find(s => s.value == props.row.state)?.label
}}
</q-badge> </q-badge>
<q-btn <q-btn
:disable='props.row.state !== "active"' :disable='props.row.state !== "active"'
@@ -210,15 +209,12 @@
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
{% endraw %}
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<q-card class="full-height"> <q-card class="full-height">
<q-card-section class="column q-gutter-y-sm"> <q-card-section class="column q-gutter-y-sm">
{% raw %}
<div <div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap" class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
> >
@@ -254,19 +250,21 @@
<q-tr :props="props"> <q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm"> <div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow"> <div class="q-my-sm col-grow">
<div class="text-subtitle1 text-bold"> <div
{{ props.row.alias }} class="text-subtitle1 text-bold"
</div> v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<q-badge <q-badge
:style="`background-color: #${props.row.color}`" :style="`background-color: #${props.row.color}`"
class="text-bold" class="text-bold"
v-text="'#'+props.row.color"
> >
#{{ props.row.color }}
</q-badge> </q-badge>
<div class="text-bold"> <div
{{ shortenNodeId(props.row.id) }} class="text-bold"
</div> v-text="shortenNodeId(props.row.id)"
></div>
<q-btn <q-btn
size="xs" size="xs"
flat flat
@@ -302,8 +300,6 @@
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
{% endraw %}
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
@@ -1,6 +1,5 @@
<q-tab-panel name="dashboard"> <q-tab-panel name="dashboard">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
{% raw %}
<lnbits-node-info :info="this.info"></lnbits-node-info> <lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm"> <div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md"> <div class="col-12 col-md-8 q-gutter-y-md">
@@ -65,6 +64,5 @@
></lnbits-channel-stats> ></lnbits-channel-stats>
</div> </div>
</div> </div>
{% endraw %}
</q-card-section> </q-card-section>
</q-tab-panel> </q-tab-panel>
@@ -3,7 +3,6 @@
<q-dialog v-model="transactionDetailsDialog.show"> <q-dialog v-model="transactionDetailsDialog.show">
<q-card class="my-card"> <q-card class="my-card">
<q-card-section> <q-card-section>
{% raw %}
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<div <div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending" v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
@@ -18,7 +17,9 @@
<div class="row q-my-md"> <div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div> <div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono"> <div class="col-9 text-wrap mono">
{{ transactionDetailsDialog.data.payment_hash }} <span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon <q-icon
name="content_copy" name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)" @click="copyText(transactionDetailsDialog.data.payment_hash)"
@@ -33,7 +34,7 @@
> >
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div> <div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono"> <div class="col-9 text-wrap mono">
{{ transactionDetailsDialog.data.preimage }} <span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon <q-icon
name="content_copy" name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)" @click="copyText(transactionDetailsDialog.data.preimage)"
@@ -66,7 +67,6 @@
></q-btn> ></q-btn>
</div> </div>
</div> </div>
{% endraw %}
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
@@ -102,7 +102,6 @@
:filter="paymentsTable.filter" :filter="paymentsTable.filter"
@request="getPayments" @request="getPayments"
> >
{% raw %}
<template v-slot:body-cell-pending="props"> <template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center"> <q-td auto-width class="text-center">
<q-icon <q-icon
@@ -211,9 +210,8 @@
<q-badge <q-badge
:style="`background-color: #${props.row.destination?.color}`" :style="`background-color: #${props.row.destination?.color}`"
class="text-bold" class="text-bold"
> v-text="props.row.destination?.alias"
{{ props.row.destination?.alias }} ></q-badge>
</q-badge>
<div> <div>
<q-btn <q-btn
size="xs" size="xs"
@@ -233,7 +231,6 @@
</div> </div>
</q-td> </q-td>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -266,7 +263,6 @@
:filter="invoiceTable.filter" :filter="invoiceTable.filter"
@request="getInvoices" @request="getInvoices"
> >
{% raw %}
<template v-slot:body-cell-pending="props"> <template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center"> <q-td auto-width class="text-center">
<q-icon <q-icon
@@ -305,8 +301,6 @@
></lnbits-date> ></lnbits-date>
</q-td> </q-td>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
-1
View File
@@ -249,7 +249,6 @@
}, },
api: function (method, url, options) { api: function (method, url, options) {
const params = new URLSearchParams(options?.query) const params = new URLSearchParams(options?.query)
params.set('usr', this.g.user.id)
return LNbits.api return LNbits.api
.request(method, `/node/api/v1${url}?${params}`, {}, options?.data) .request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
.catch(error => { .catch(error => {
@@ -1,7 +1,6 @@
// update cache version every time there is a new deployment // update cache version every time there is a new deployment
// so the service worker reinitializes the cache // so the service worker reinitializes the cache
const CACHE_VERSION = 86 const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => { const getApiKey = request => {
let api_key = request.headers.get('X-Api-Key') let api_key = request.headers.get('X-Api-Key')
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
caches.keys().then(cacheNames => { caches.keys().then(cacheNames => {
return Promise.all( return Promise.all(
cacheNames.map(cacheName => { cacheNames.map(cacheName => {
const currentCacheVersion = cacheName.split('-').slice(-2, 2) if (!cacheName.startsWith(CURRENT_CACHE)) {
if (currentCacheVersion !== CACHE_VERSION) {
return caches.delete(cacheName) return caches.delete(cacheName)
} }
}) })
+15
View File
@@ -17,9 +17,11 @@ from lnbits.core.services import (
update_cached_settings, update_cached_settings,
update_wallet_balance, update_wallet_balance,
) )
from lnbits.core.tasks import api_invoice_listeners
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, UpdateSettings, settings from lnbits.settings import AdminSettings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from .. import core_app_extra from .. import core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
@@ -48,6 +50,19 @@ async def api_auditor():
) )
@admin_router.get(
"/admin/api/v1/monitor",
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
)
async def api_monitor():
return {
"invoice_listeners": list(invoice_listeners.keys()),
"api_invoice_listeners": list(api_invoice_listeners.keys()),
}
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings]) @admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
async def api_get_settings( async def api_get_settings(
user: User = Depends(check_admin), user: User = Depends(check_admin),
+98 -28
View File
@@ -4,6 +4,7 @@ import json
import uuid import uuid
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from math import ceil
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
@@ -31,6 +32,7 @@ from lnbits.core.helpers import (
stop_extension_background_work, stop_extension_background_work,
) )
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet,
ConversionData, ConversionData,
CreateInvoice, CreateInvoice,
CreateLnurl, CreateLnurl,
@@ -48,7 +50,9 @@ from lnbits.core.models import (
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_access_token,
check_admin, check_admin,
check_user_exists,
get_key_type, get_key_type,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
@@ -60,13 +64,14 @@ from lnbits.extension_manager import (
ExtensionRelease, ExtensionRelease,
InstallableExtension, InstallableExtension,
fetch_github_release_config, fetch_github_release_config,
fetch_release_payment_info,
get_valid_extensions, get_valid_extensions,
) )
from lnbits.helpers import generate_filter_params_openapi, url_for from lnbits.helpers import generate_filter_params_openapi, url_for
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
currencies, allowed_currencies,
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
@@ -81,6 +86,7 @@ from ..crud import (
delete_wallet, delete_wallet,
drop_extension_db, drop_extension_db,
get_dbversions, get_dbversions,
get_installed_extension,
get_payments, get_payments,
get_payments_history, get_payments_history,
get_payments_paginated, get_payments_paginated,
@@ -123,6 +129,15 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat} return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@api_router.get(
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
)
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
return [BaseWallet(**w.dict()) for w in user.wallets]
@api_router.put("/api/v1/wallet/{new_name}") @api_router.put("/api/v1/wallet/{new_name}")
async def api_update_wallet_name( async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
@@ -407,9 +422,15 @@ async def api_payments_pay_lnurl(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try: try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get( r = await client.get(
data.callback, data.callback,
params={"amount": data.amount, "comment": data.comment}, params={"amount": amount_msat, "comment": data.comment},
timeout=40, timeout=40,
) )
if r.is_error: if r.is_error:
@@ -435,13 +456,13 @@ async def api_payments_pay_lnurl(
) )
invoice = bolt11.decode(params["pr"]) invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != data.amount: if invoice.amount_msat != amount_msat:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=( detail=(
( (
f"{domain} returned an invalid invoice. Expected" f"{domain} returned an invalid invoice. Expected"
f" {data.amount} msat, got {invoice.amount_msat}." f" {amount_msat} msat, got {invoice.amount_msat}."
), ),
), ),
) )
@@ -452,6 +473,9 @@ async def api_payments_pay_lnurl(
extra["success_action"] = params["successAction"] extra["success_action"] = params["successAction"]
if data.comment: if data.comment:
extra["comment"] = data.comment extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required" assert data.description is not None, "description is required"
payment_hash = await pay_invoice( payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
@@ -515,10 +539,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys # We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not # If a valid key is given, we also return the field "details", otherwise not
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
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 = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None payment_hash, wallet_id=wallet.id if wallet else None
) )
@@ -701,14 +722,8 @@ async def api_perform_lnurlauth(
@api_router.get("/api/v1/currencies") @api_router.get("/api/v1/currencies")
async def api_list_currencies_available(): async def api_list_currencies_available() -> List[str]:
if len(settings.lnbits_allowed_currencies) > 0: return allowed_currencies()
return [
item
for item in currencies.keys()
if item.upper() in settings.lnbits_allowed_currencies
]
return list(currencies.keys())
@api_router.post("/api/v1/conversion") @api_router.post("/api/v1/conversion")
@@ -780,10 +795,12 @@ async def websocket_update_get(item_id: str, data: str):
@api_router.post("/api/v1/extension") @api_router.post("/api/v1/extension")
async def api_install_extension( async def api_install_extension(
data: CreateExtension, user: User = Depends(check_admin) data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
): ):
release = await InstallableExtension.get_extension_release( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive data.ext_id, data.source_repo, data.archive, data.version
) )
if not release: if not release:
raise HTTPException( raise HTTPException(
@@ -795,13 +812,17 @@ async def api_install_extension(
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version" status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
) )
release.payment_hash = data.payment_hash
ext_info = InstallableExtension( ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
) )
ext_info.download_archive()
try: try:
installed_ext = await get_installed_extension(data.ext_id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive() ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info) extension = Extension.from_installable_ext(ext_info)
@@ -811,8 +832,9 @@ async def api_install_extension(
await add_installed_extension(ext_info) await add_installed_extension(ext_info)
# call stop while the old routes are still active if extension.is_upgrade_extension:
await stop_extension_background_work(data.ext_id, user.id) # call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token)
if data.ext_id not in settings.lnbits_deactivated_extensions: if data.ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [data.ext_id] settings.lnbits_deactivated_extensions += [data.ext_id]
@@ -824,7 +846,8 @@ async def api_install_extension(
ext_info.nofiy_upgrade() ext_info.nofiy_upgrade()
return extension return extension
except AssertionError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
ext_info.clean_extension_files() ext_info.clean_extension_files()
@@ -838,7 +861,11 @@ async def api_install_extension(
@api_router.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)): async def api_uninstall_extension(
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
installable_extensions = await InstallableExtension.get_installable_extensions() installable_extensions = await InstallableExtension.get_installable_extensions()
extensions = [e for e in installable_extensions if e.id == ext_id] extensions = [e for e in installable_extensions if e.id == ext_id]
@@ -864,7 +891,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
try: try:
# call stop while the old routes are still active # call stop while the old routes are still active
await stop_extension_background_work(ext_id, user.id) await stop_extension_background_work(ext_id, user.id, access_token)
if ext_id not in settings.lnbits_deactivated_extensions: if ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [ext_id] settings.lnbits_deactivated_extensions += [ext_id]
@@ -885,9 +912,18 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
) )
async def get_extension_releases(ext_id: str): async def get_extension_releases(ext_id: str):
try: try:
extension_releases: List[ extension_releases: List[ExtensionRelease] = (
ExtensionRelease await InstallableExtension.get_extension_releases(ext_id)
] = await InstallableExtension.get_extension_releases(ext_id) )
installed_ext = await get_installed_extension(ext_id)
if not installed_ext:
return extension_releases
for release in extension_releases:
payment_info = installed_ext.find_existing_payment(release.pay_link)
if payment_info:
release.paid_sats = payment_info.amount
return extension_releases return extension_releases
@@ -897,6 +933,40 @@ async def get_extension_releases(ext_id: str):
) )
@api_router.put("/api/v1/extension/invoice", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension):
try:
assert data.cost_sats, "A non-zero amount must be specified"
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
assert release, "Release not found"
assert release.pay_link, "Pay link not found for release"
payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats
)
assert payment_info and payment_info.payment_request, "Cannot request invoice"
invoice = bolt11.decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing"
invoice_amount = int(invoice.amount_msat / 1000)
assert (
invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}."
assert (
payment_info.payment_hash == invoice.payment_hash
), "Wroong invoice payment hash"
return payment_info
except AssertionError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
except Exception as ex:
logger.warning(ex)
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice")
@api_router.get( @api_router.get(
"/api/v1/extension/release/{org}/{repo}/{tag_name}", "/api/v1/extension/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
+346
View File
@@ -0,0 +1,346 @@
import importlib
from typing import Callable, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
from lnbits.decorators import check_user_exists
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
encrypt_internal_message,
is_valid_email_address,
is_valid_username,
)
from lnbits.settings import AuthMethods, settings
from ..crud import (
create_account,
create_user,
get_account,
get_account_by_email,
get_account_by_username_or_email,
get_user,
update_account,
update_user_password,
verify_user_password,
)
from ..models import (
CreateUser,
LoginUsernamePassword,
LoginUsr,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
User,
UserConfig,
)
auth_router = APIRouter()
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
return user
@auth_router.post("/api/v1/auth", description="Login via the username and password")
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
)
try:
user = await get_account_by_username_or_email(data.username)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
except HTTPException as e:
raise e
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
@auth_router.post("/api/v1/auth/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
try:
user = await get_user(data.usr)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
except HTTPException as e:
raise e
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request, provider: str, user_id: Optional[str] = None
):
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
)
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso:
state = encrypt_internal_message(user_id)
return await provider_sso.get_login_redirect(state=state)
@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback")
async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
)
try:
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
assert userinfo is not None
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
except HTTPException as e:
raise e
except ValueError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.",
)
@auth_router.post("/api/v1/auth/logout")
async def logout() -> JSONResponse:
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("lnbits_last_active_wallet")
return response
@auth_router.post("/api/v1/auth/register")
async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
)
if data.password != data.password_repeat:
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
if not data.username:
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
if not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
try:
user = await create_user(data)
return _auth_success_response(user.username)
except ValueError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
@auth_router.put("/api/v1/auth/password")
async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists)
) -> Optional[User]:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
return await update_user_password(data)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
)
@auth_router.put("/api/v1/auth/update")
async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
try:
return await update_account(user.id, data.username, None, data.config)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
@auth_router.put("/api/v1/auth/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
try:
await update_account(
user_id=settings.super_user,
username=data.username,
user_config=UserConfig(provider="lnbits"),
)
super_user = UpdateUserPassword(
user_id=settings.super_user,
password=data.password,
password_repeat=data.password_repeat,
username=data.username,
)
await update_user_password(super_user)
settings.first_install = False
return _auth_success_response(username=super_user.username)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email
if not email or not is_valid_email_address(email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
user_config = UserConfig(**dict(userinfo))
user_config.email_verified = True
account = await get_account_by_email(email)
if verified_user_id:
if account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/account"
if account:
user = await update_account(account.id, email=email, user_config=user_config)
else:
if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_account(email=email, user_config=user_config)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
return _auth_redirect_response(redirect_path, email)
def _auth_success_response(
username: Optional[str] = None,
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
access_token = create_access_token(
data={"sub": username or "", "usr": user_id, "email": email}
)
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
return response
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email})
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
return response
def _new_sso(provider: str) -> Optional[SSOBase]:
try:
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
return None
client_id = getattr(settings, f"{provider}_client_id", None)
client_secret = getattr(settings, f"{provider}_client_secret", None)
discovery_url = getattr(settings, f"{provider}_discovery_url", None)
if not client_id or not client_secret:
logger.warning(f"{provider} auth allowed but not configured.")
return None
SSOProviderClass = _find_auth_provider_class(provider)
ssoProvider = SSOProviderClass(
client_id, client_secret, None, allow_insecure_http=True
)
if (
discovery_url
and getattr(ssoProvider, "discovery_url", discovery_url) != discovery_url
):
ssoProvider.discovery_url = discovery_url
return ssoProvider
except Exception as e:
logger.warning(e)
return None
def _find_auth_provider_class(provider: str) -> Callable:
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
for module in sso_modules:
try:
provider_module = importlib.import_module(f"{module}.{provider}")
ProviderClass = getattr(provider_module, f"{provider.title()}SSO")
if ProviderClass:
return ProviderClass
except Exception:
pass
raise ValueError(f"No SSO provider found for '{provider}'.")
+127 -54
View File
@@ -1,17 +1,18 @@
import asyncio import asyncio
import sys
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import Depends, Query, Request, status from fastapi import Cookie, Depends, Query, Request, status
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.db import db from lnbits.core.db import core_app_extra, db
from lnbits.core.helpers import to_valid_user_id from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_admin, check_user_exists from lnbits.decorators import check_admin, check_user_exists
@@ -20,7 +21,7 @@ from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_wallet_class
from ...extension_manager import InstallableExtension, get_valid_extensions from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import currencies from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import ( from ..crud import (
create_account, create_account,
create_wallet, create_wallet,
@@ -52,6 +53,22 @@ async def home(request: Request, lightning: str = ""):
) )
@generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request):
if not settings.first_install:
return template_renderer().TemplateResponse(
"error.html",
{
"request": request,
"err": "Super user account has already been configured.",
},
)
return template_renderer().TemplateResponse(
"core/first_install.html",
{"request": request},
)
@generic_router.get("/robots.txt", response_class=HTMLResponse) @generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots(): async def robots():
data = """ data = """
@@ -74,18 +91,14 @@ async def extensions_install(
): ):
await toggle_extension(enable, disable, user.id) await toggle_extension(enable, disable, user.id)
# Update user as his extensions have been updated
if enable or disable:
user = await get_user(user.id) # type: ignore
try: try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions() installed_exts: List["InstallableExtension"] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
installable_exts: List[ installable_exts = await InstallableExtension.get_installable_extensions()
InstallableExtension installable_exts_ids = [e.id for e in installable_exts]
] = await InstallableExtension.get_installable_extensions()
installable_exts += [ installable_exts += [
e for e in installed_exts if e.id not in installed_exts_ids e for e in installed_exts if e.id not in installable_exts_ids
] ]
for e in installable_exts: for e in installable_exts:
@@ -103,20 +116,28 @@ async def extensions_install(
try: try:
ext_id = activate or deactivate ext_id = activate or deactivate
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
if ext_id and user.admin: if ext_id and user.admin:
if deactivate and deactivate not in settings.lnbits_deactivated_extensions: if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [deactivate] settings.lnbits_deactivated_extensions += [deactivate]
elif activate: elif activate:
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions = list( settings.lnbits_deactivated_extensions = list(
filter( filter(
lambda e: e != activate, settings.lnbits_deactivated_extensions lambda e: e != activate, settings.lnbits_deactivated_extensions
) )
) )
await update_installed_extension_state( await update_installed_extension_state(
ext_id=ext_id, active=activate is not None ext_id=ext_id, active=activate is not None
) )
all_extensions = list(map(lambda e: e.code, get_valid_extensions())) all_ext_ids = list(map(lambda e: e.code, all_extensions))
inactive_extensions = await get_inactive_extensions() inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions() db_version = await get_dbversions()
extensions = list( extensions = list(
@@ -131,7 +152,7 @@ async def extensions_install(
"dependencies": ext.dependencies, "dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids, "isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version, "hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_extensions, "isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions, "isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions, "isActive": ext.id not in inactive_extensions,
"latestRelease": ( "latestRelease": (
@@ -145,6 +166,9 @@ async def extensions_install(
) )
) )
# refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"core/extensions.html", "core/extensions.html",
{ {
@@ -165,59 +189,59 @@ async def extensions_install(
) )
async def wallet( async def wallet(
request: Request, request: Request,
usr: UUID4 = Query(...), lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
user: User = Depends(check_user_exists),
wal: Optional[UUID4] = Query(None), wal: Optional[UUID4] = Query(None),
): ):
user_id = usr.hex if wal:
user = await get_user(user_id)
if not user:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User does not exist."}
)
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 wallet_id = wal.hex
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user = await get_user(user_id=user.id) or user
wallet_id = wallet.id
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet_id = lnbits_last_active_wallet
else:
wallet_id = user.wallets[0].id
userwallet = user.get_wallet(wallet_id) user_wallet = user.get_wallet(wallet_id)
if not userwallet or userwallet.deleted: if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"} "error.html", {"request": request, "err": "Wallet not found"}
) )
if ( resp = template_renderer().TemplateResponse(
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", "core/wallet.html",
{ {
"request": request, "request": request,
"user": user.dict(), "user": user.dict(),
"wallet": userwallet.dict(), "wallet": user_wallet.dict(),
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee, "service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max, "service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest", "web_manifest": f"/manifest/{user.id}.webmanifest",
}, },
) )
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
return resp
@generic_router.get(
"/account",
response_class=HTMLResponse,
description="show account page",
)
async def account(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
"core/account.html",
{
"request": request,
"user": user.dict(),
},
)
@generic_router.get("/withdraw", response_class=JSONResponse) @generic_router.get("/withdraw", response_class=JSONResponse)
@@ -329,9 +353,16 @@ async def lnurlwallet(request: Request):
) )
@generic_router.get("/service-worker.js", response_class=FileResponse) @generic_router.get("/service-worker.js")
async def service_worker(): async def service_worker(request: Request):
return FileResponse(Path("lnbits", "static", "js", "service-worker.js")) return template_renderer().TemplateResponse(
"service-worker.js",
{
"request": request,
"cache_version": settings.server_startup_time,
},
media_type="text/javascript",
)
@generic_router.get("/manifest/{usr}.webmanifest") @generic_router.get("/manifest/{usr}.webmanifest")
@@ -352,9 +383,44 @@ async def manifest(request: Request, usr: str):
if settings.lnbits_custom_logo if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png" else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
), ),
"sizes": "512x512",
"type": "image/png", "type": "image/png",
"sizes": "900x900", },
} {"src": "/static/favicon.ico", "sizes": "32x32", "type": "image/x-icon"},
{
"src": "/static/images/maskable_icon_x192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable",
},
{
"src": "/static/images/maskable_icon_x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/static/images/maskable_icon.png",
"type": "image/png",
"sizes": "1024x1024",
"purpose": "maskable",
},
],
"screenshots": [
{
"src": "/static/images/screenshot_desktop.png",
"sizes": "2394x1314",
"type": "image/png",
"form_factor": "wide",
"label": "LNbits - Desktop screenshot",
},
{
"src": "/static/images/screenshot_phone.png",
"sizes": "1080x1739",
"type": "image/png",
"form_factor": "narrow",
"label": "LNbits - Phone screenshot",
},
], ],
"start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}", "start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}",
"background_color": "#1F2234", "background_color": "#1F2234",
@@ -368,6 +434,13 @@ async def manifest(request: Request, usr: str):
"short_name": wallet.name, "short_name": wallet.name,
"description": wallet.name, "description": wallet.name,
"url": f"/wallet?usr={usr}&wal={wallet.id}", "url": f"/wallet?usr={usr}&wal={wallet.id}",
"icons": [
{
"src": "/static/images/maskable_icon_x96.png",
"sizes": "96x96",
"type": "image/png",
}
],
} }
for wallet in user.wallets for wallet in user.wallets
], ],
+5 -3
View File
@@ -108,9 +108,11 @@ async def api_delete_channel(
) -> Optional[List[NodeChannel]]: ) -> Optional[List[NodeChannel]]:
return await node.close_channel( return await node.close_channel(
short_id, short_id,
ChannelPoint(funding_txid=funding_txid, output_index=output_index) (
if funding_txid is not None and output_index is not None ChannelPoint(funding_txid=funding_txid, output_index=output_index)
else None, if funding_txid is not None and output_index is not None
else None
),
force, force,
) )
+9 -8
View File
@@ -9,7 +9,8 @@ from starlette.responses import RedirectResponse
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type, require_admin_key,
require_invoice_key,
) )
from ..crud import ( from ..crud import (
@@ -28,15 +29,15 @@ tinyurl_router = APIRouter()
description="creates a tinyurl", description="creates a tinyurl",
) )
async def api_create_tinyurl( async def api_create_tinyurl(
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type) url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
tinyurls = await get_tinyurl_by_url(url) tinyurls = await get_tinyurl_by_url(url)
try: try:
for tinyurl in tinyurls: for tinyurl in tinyurls:
if tinyurl: if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey: if tinyurl.wallet == wallet.wallet.id:
return tinyurl return tinyurl
return await create_tinyurl(url, endless, wallet.wallet.inkey) return await create_tinyurl(url, endless, wallet.wallet.id)
except Exception: except Exception:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl" status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
@@ -49,12 +50,12 @@ async def api_create_tinyurl(
description="get a tinyurl by id", description="get a tinyurl by id",
) )
async def api_get_tinyurl( async def api_get_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type) tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
): ):
try: try:
tinyurl = await get_tinyurl(tinyurl_id) tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl: if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey: if tinyurl.wallet == wallet.wallet.id:
return tinyurl return tinyurl
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided." status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
@@ -71,12 +72,12 @@ async def api_get_tinyurl(
description="delete a tinyurl by id", description="delete a tinyurl by id",
) )
async def api_delete_tinyurl( async def api_delete_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type) tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
try: try:
tinyurl = await get_tinyurl(tinyurl_id) tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl: if tinyurl:
if tinyurl.wallet == wallet.wallet.inkey: if tinyurl.wallet == wallet.wallet.id:
await delete_tinyurl(tinyurl_id) await delete_tinyurl(tinyurl_id)
return {"deleted": True} return {"deleted": True}
raise HTTPException( raise HTTPException(
+23 -2
View File
@@ -91,6 +91,12 @@ class Compat:
return "(strftime('%s', 'now'))" return "(strftime('%s', 'now'))"
return "<nothing>" return "<nothing>"
@property
def timestamp_column_default(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return self.timestamp_now
return "NULL"
@property @property
def serial_primary_key(self) -> str: def serial_primary_key(self) -> str:
if self.type in {POSTGRES, COCKROACH}: if self.type in {POSTGRES, COCKROACH}:
@@ -173,16 +179,27 @@ class Connection(Compat):
values: Optional[List[str]] = None, values: Optional[List[str]] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None, model: Optional[Type[TRowModel]] = None,
group_by: Optional[List[str]] = None,
) -> Page[TRowModel]: ) -> Page[TRowModel]:
if not filters: if not filters:
filters = Filters() filters = Filters()
clause = filters.where(where) clause = filters.where(where)
parsed_values = filters.values(values) parsed_values = filters.values(values)
group_by_string = ""
if group_by:
for field in group_by:
if not re.fullmatch(
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
):
raise ValueError("Value for GROUP BY is invalid")
group_by_string = f"GROUP BY {', '.join(group_by)}"
rows = await self.fetchall( rows = await self.fetchall(
f""" f"""
{query} {query}
{clause} {clause}
{group_by_string}
{filters.order_by()} {filters.order_by()}
{filters.pagination()} {filters.pagination()}
""", """,
@@ -196,6 +213,7 @@ class Connection(Compat):
SELECT COUNT(*) FROM ( SELECT COUNT(*) FROM (
{query} {query}
{clause} {clause}
{group_by_string}
) as count ) as count
""", """,
parsed_values, parsed_values,
@@ -236,7 +254,9 @@ class Database(Compat):
else: else:
self.schema = None self.schema = None
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY) self.engine = create_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@@ -282,9 +302,10 @@ class Database(Compat):
values: Optional[List[str]] = None, values: Optional[List[str]] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[Type[TRowModel]] = None, model: Optional[Type[TRowModel]] = None,
group_by: Optional[List[str]] = None,
) -> Page[TRowModel]: ) -> Page[TRowModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model) return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: tuple = ()):
async with self.connect() as conn: async with self.connect() as conn:
+82 -46
View File
@@ -1,21 +1,29 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Literal, Optional, Type from typing import Annotated, Literal, Optional, Type, Union
from fastapi import Query, Request, Security from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.crud import (
from lnbits.core.models import User, WalletType, WalletTypeInfo get_account,
get_account_by_email,
get_account_by_username,
get_user,
get_wallet_for_key,
)
from lnbits.core.models import User, Wallet, WalletType, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel from lnbits.db import Filter, Filters, TFilterModel
from lnbits.requestvars import g from lnbits.settings import AuthMethods, settings
from lnbits.settings import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
# TODO: fix type ignores
class KeyChecker(SecurityBase): class KeyChecker(SecurityBase):
def __init__( def __init__(
self, self,
@@ -24,23 +32,25 @@ class KeyChecker(SecurityBase):
api_key: Optional[str] = None, api_key: Optional[str] = None,
): ):
self.scheme_name = scheme_name or self.__class__.__name__ self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error self.auto_error: bool = auto_error
self._key_type = WalletType.invoice self._key_type: WalletType = WalletType.invoice
self._api_key = api_key self._api_key = api_key
if api_key: if api_key:
key = APIKey( openapi_model = APIKey(
**{"in": APIKeyIn.query}, # type: ignore **{"in": APIKeyIn.query},
type=SecuritySchemeType.apiKey,
name="X-API-KEY", name="X-API-KEY",
description="Wallet API Key - QUERY", description="Wallet API Key - QUERY",
) )
else: else:
key = APIKey( openapi_model = APIKey(
**{"in": APIKeyIn.header}, # type: ignore **{"in": APIKeyIn.header},
type=SecuritySchemeType.apiKey,
name="X-API-KEY", name="X-API-KEY",
description="Wallet API Key - HEADER", description="Wallet API Key - HEADER",
) )
self.wallet = None self.wallet: Optional[Wallet] = None
self.model: APIKey = key self.model: APIKey = openapi_model
async def __call__(self, request: Request): async def __call__(self, request: Request):
try: try:
@@ -53,12 +63,12 @@ class KeyChecker(SecurityBase):
# avoided here. Also, we should not return the wallet here - thats # avoided here. Also, we should not return the wallet here - thats
# silly. Possibly store it in a Redis DB # silly. Possibly store it in a Redis DB
wallet = await get_wallet_for_key(key_value, self._key_type) wallet = await get_wallet_for_key(key_value, self._key_type)
if not wallet or wallet.deleted: if not wallet:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid key or wallet.", detail="Invalid key or wallet.",
) )
self.wallet = wallet # type: ignore self.wallet = wallet
except KeyError: except KeyError:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing." status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
@@ -220,44 +230,50 @@ async def require_invoice_key(
return wallet return wallet
async def check_user_exists(usr: UUID4) -> User: async def check_access_token(
g().user = await get_user(usr.hex) header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
if not g().user: ) -> Optional[str]:
raise HTTPException( return header_access_token or cookie_access_token
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
if (
len(settings.lnbits_allowed_users) > 0
and g().user.id not in settings.lnbits_allowed_users
and g().user.id not in settings.lnbits_admin_users
and g().user.id != settings.super_user
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return g().user
async def check_admin(usr: UUID4) -> User: async def check_user_exists(
user = await check_user_exists(usr) r: Request,
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token)
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
account = await get_account(usr.hex)
else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account or not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account.id)
assert user, "User not found for account."
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
return user
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users: if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
detail="User not authorized. No admin privileges.",
) )
return user return user
async def check_super_user(usr: UUID4) -> User: async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
user = await check_admin(usr)
if user.id != settings.super_user: if user.id != settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
detail="User not authorized. No super user privileges.",
) )
return user return user
@@ -295,3 +311,23 @@ def parse_filters(model: Type[TFilterModel]):
) )
return dependency return dependency
async def _get_account_from_token(access_token):
try:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
)
except JWTError as e:
logger.debug(e)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
+135 -30
View File
@@ -1,16 +1,15 @@
import asyncio
import hashlib import hashlib
import json import json
import os import os
import shutil import shutil
import sys import sys
import zipfile import zipfile
from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple from typing import Any, List, NamedTuple, Optional, Tuple
from urllib import request from urllib import request
import httpx import httpx
from fastapi import HTTPException
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from pydantic import BaseModel from pydantic import BaseModel
@@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str] warning: Optional[str]
info_notification: Optional[str] info_notification: Optional[str]
critical_notification: Optional[str] critical_notification: Optional[str]
pay_link: Optional[str]
def is_version_compatible(self): def is_version_compatible(self):
if not self.min_lnbits_version: if not self.min_lnbits_version:
@@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
return version_parse(self.min_lnbits_version) <= version_parse(settings.version) return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None
pay_link: Optional[str] = None
payment_hash: Optional[str] = None
payment_request: Optional[str] = None
def download_url(url, save_path): def download_url(url, save_path):
with request.urlopen(url) as dl_file: with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file: with open(save_path, "wb") as out_file:
out_file.write(dl_file.read()) out_file.write(dl_file.read())
@@ -155,6 +162,21 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
return resp.json() return resp.json()
async def fetch_release_payment_info(
url: str, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
if amount:
url = f"{url}?amount={amount}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str: def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path: if not path:
return "" return ""
@@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
repo: Optional[str] = None repo: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
pay_link: Optional[str] = None
cost_sats: Optional[int] = None
paid_sats: Optional[int] = 0
payment_hash: Optional[str] = None
@property
def archive_url(self) -> str:
if not self.pay_link:
return self.archive
return (
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
)
async def check_payment_requirements(self):
if not self.pay_link:
return
payment_info = await fetch_release_payment_info(self.pay_link)
self.cost_sats = payment_info.amount if payment_info else None
@classmethod @classmethod
def from_github_release( def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease" cls, source_repo: str, r: "GitHubRepoRelease"
@@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(), is_version_compatible=e.is_version_compatible(),
warning=e.warning, warning=e.warning,
html_url=e.html_url, html_url=e.html_url,
pay_link=e.pay_link,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@classmethod @classmethod
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]: async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
try: try:
github_releases = await fetch_github_releases(org, repo) github_releases = await fetch_github_releases(org, repo)
return [ return [
@@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
featured = False featured = False
latest_release: Optional[ExtensionRelease] = None latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
archive: Optional[str] = None archive: Optional[str] = None
@property @property
@@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
return self.installed_release.version return self.installed_release.version
return "" return ""
def download_archive(self): async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).") logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path ext_zip_file = self.zip_path
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
try: try:
assert self.installed_release, "installed_release is none." assert self.installed_release, "installed_release is none."
download_url(self.installed_release.archive, ext_zip_file)
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise AssertionError("Cannot fetch extension archive file")
status_code=HTTPStatus.NOT_FOUND,
detail="Cannot fetch extension archive file",
)
archive_hash = file_hash(ext_zip_file) archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash: if self.installed_release.hash and self.installed_release.hash != archive_hash:
# remove downloaded archive # remove downloaded archive
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
raise HTTPException( raise AssertionError("File hash missmatch. Will not install.")
status_code=HTTPStatus.NOT_FOUND,
detail="File hash missmatch. Will not install.",
)
def extract_archive(self): def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).") logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
@@ -468,14 +514,54 @@ class InstallableExtension(BaseModel):
if version_parse(self.latest_release.version) < version_parse(release.version): if version_parse(self.latest_release.version) < version_parse(release.version):
self.latest_release = release self.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link:
return None
return next(
(p for p in self.payments if p.pay_link == pay_link),
None,
)
def _restore_payment_info(self):
if not self.installed_release:
return
if not self.installed_release.pay_link:
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
if payment_info:
self.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if not self.installed_release or not self.installed_release.pay_link:
return
payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats,
pay_link=self.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash,
)
self.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link
]
self.payments.append(payment_info)
@classmethod @classmethod
def from_row(cls, data: dict) -> "InstallableExtension": def from_row(cls, data: dict) -> "InstallableExtension":
meta = json.loads(data["meta"]) meta = json.loads(data["meta"])
ext = InstallableExtension(**data) ext = InstallableExtension(**data)
if "installed_release" in meta: if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"]) ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext return ext
@classmethod
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
return [InstallableExtension.from_row(row) for row in rows]
@classmethod @classmethod
async def from_github_release( async def from_github_release(
cls, github_release: GitHubRelease cls, github_release: GitHubRelease
@@ -565,17 +651,19 @@ class InstallableExtension(BaseModel):
try: try:
manifest = await fetch_manifest(url) manifest = await fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
if r.id == ext_id: if r.id != ext_id:
repo_releases = await ExtensionRelease.all_releases( continue
r.organisation, r.repository repo_releases = await ExtensionRelease.get_github_releases(
) r.organisation, r.repository
extension_releases += repo_releases )
extension_releases += repo_releases
for e in manifest.extensions: for e in manifest.extensions:
if e.id == ext_id: if e.id != ext_id:
extension_releases += [ continue
ExtensionRelease.from_explicit_release(url, e) explicit_release = ExtensionRelease.from_explicit_release(url, e)
] await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
except Exception as e: except Exception as e:
logger.warning(f"Manifest {url} failed with '{str(e)}'") logger.warning(f"Manifest {url} failed with '{str(e)}'")
@@ -584,15 +672,17 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_release( async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]: ) -> Optional["ExtensionRelease"]:
all_releases: List[ all_releases: List[ExtensionRelease] = (
ExtensionRelease await InstallableExtension.get_extension_releases(ext_id)
] = await InstallableExtension.get_extension_releases(ext_id) )
selected_release = [ selected_release = [
r r
for r in all_releases for r in all_releases
if r.archive == archive and r.source_repo == source_repo if r.archive == archive
and r.source_repo == source_repo
and r.version == version
] ]
return selected_release[0] if len(selected_release) != 0 else None return selected_release[0] if len(selected_release) != 0 else None
@@ -602,13 +692,28 @@ class CreateExtension(BaseModel):
ext_id: str ext_id: str
archive: str archive: str
source_repo: str source_repo: str
version: str
cost_sats: Optional[int] = 0
payment_hash: Optional[str] = None
def get_valid_extensions() -> List[Extension]: def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
return [ valid_extensions = [
extension for extension in ExtensionManager().extensions if extension.is_valid extension for extension in ExtensionManager().extensions if extension.is_valid
] ]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
def version_parse(v: str): def version_parse(v: str):
""" """
+41 -5
View File
@@ -1,9 +1,12 @@
import json import json
import re
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Type from typing import Any, List, Optional, Type
import jinja2 import jinja2
import shortuuid import shortuuid
from jose import jwt
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.schema import field_schema from pydantic.schema import field_schema
@@ -11,6 +14,7 @@ from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class from lnbits.nodes import get_node_class
from lnbits.requestvars import g from lnbits.requestvars import g
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from .db import FilterModel from .db import FilterModel
from .extension_manager import get_valid_extensions from .extension_manager import get_valid_extensions
@@ -58,6 +62,7 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
t.env.globals["LNBITS_VERSION"] = settings.version t.env.globals["LNBITS_VERSION"] = settings.version
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
@@ -66,11 +71,7 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None settings.lnbits_node_ui and get_node_class() is not None
) )
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = [ t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
e
for e in get_valid_extensions()
if e.code not in settings.lnbits_deactivated_extensions
]
if settings.lnbits_custom_logo: if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
@@ -91,6 +92,10 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
def get_current_extension_name() -> str: def get_current_extension_name() -> str:
""" """
DEPRECATED: Use the repo name instead, will be removed in the future
before: `register_invoice_listener(invoice_queue, get_current_extension_name())`
after: `register_invoice_listener(invoice_queue, "my-extension")`
Returns the name of the extension that calls this method. Returns the name of the extension that calls this method.
""" """
import inspect import inspect
@@ -166,3 +171,34 @@ def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?")
""" """
query = ", ".join([f"{field} = ?" for field in model.dict().keys()]) query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
return f"UPDATE {table_name} SET {query} {where}" return f"UPDATE {table_name} SET {query} {where}"
def is_valid_email_address(email: str) -> bool:
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
return re.fullmatch(email_regex, email) is not None
def is_valid_username(username: str) -> bool:
username_regex = r"(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]"
return re.fullmatch(username_regex, username) is not None
def create_access_token(data: dict):
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
to_encode = data.copy()
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
"""Encrypt message with the internal secret key"""
if not m:
return None
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
"""Decrypt message with the internal secret key"""
if not m:
return None
return AESCipher(key=settings.auth_secret_key).decrypt(m)
+1 -1
View File
@@ -8,8 +8,8 @@ from starlette.templating import Jinja2Templates as SuperJinja2Templates
class Jinja2Templates(SuperJinja2Templates): class Jinja2Templates(SuperJinja2Templates):
def __init__(self, loader: BaseLoader) -> None: def __init__(self, loader: BaseLoader) -> None:
super().__init__("")
self.env = self.get_environment(loader) self.env = self.get_environment(loader)
super().__init__(env=self.env)
def get_environment(self, loader: BaseLoader) -> Environment: def get_environment(self, loader: BaseLoader) -> Environment:
@pass_context @pass_context
+22 -27
View File
@@ -1,9 +1,8 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any, List, Tuple, Union from typing import Any, List, Tuple, Union
from urllib.parse import parse_qs
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
@@ -41,13 +40,6 @@ class InstalledExtensionMiddleware:
await response(scope, receive, send) await response(scope, receive, send)
return return
if not self._user_allowed_to_extension(top_path, scope):
response = self._response_by_accepted_type(
headers, "User not authorized.", HTTPStatus.FORBIDDEN
)
await response(scope, receive, send)
return
# static resources do not require redirect # static resources do not require redirect
if rest[0:1] == ["static"]: if rest[0:1] == ["static"]:
await self.app(scope, receive, send) await self.app(scope, receive, send)
@@ -68,23 +60,6 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _user_allowed_to_extension(self, ext_name: str, scope) -> bool:
if ext_name not in settings.lnbits_admin_extensions:
return True
if "query_string" not in scope:
return True
# parse the URL query string into a `dict`
q = parse_qs(scope["query_string"].decode("UTF-8"))
user = q.get("usr", [""])[0]
if not user:
return True
if user == settings.super_user or user in settings.lnbits_admin_users:
return True
return False
def _response_by_accepted_type( def _response_by_accepted_type(
self, headers: List[Any], msg: str, status_code: HTTPStatus self, headers: List[Any], msg: str, status_code: HTTPStatus
) -> Union[HTMLResponse, JSONResponse]: ) -> Union[HTMLResponse, JSONResponse]:
@@ -212,7 +187,12 @@ class ExtensionsRedirectMiddleware:
def add_ratelimit_middleware(app: FastAPI): def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # latest https://slowapi.readthedocs.io/en/latest/
# shows this as a valid way to add the handler
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler, # type: ignore
)
app.add_middleware(SlowAPIMiddleware) app.add_middleware(SlowAPIMiddleware)
@@ -235,3 +215,18 @@ def add_ip_block_middleware(app: FastAPI):
return await call_next(request) return await call_next(request)
app.middleware("http")(block_allow_ip_middleware) app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
async def first_install_middleware(request: Request, call_next):
if (
settings.first_install
and request.url.path != "/api/v1/auth/first_install"
and request.url.path != "/first_install"
and not request.url.path.startswith("/static")
):
return RedirectResponse("/first_install")
return await call_next(request)
app.middleware("http")(first_install_middleware)
+18 -10
View File
@@ -217,17 +217,21 @@ class CoreLightningNode(Node):
state=( state=(
ChannelState.ACTIVE ChannelState.ACTIVE
if ch["state"] == "CHANNELD_NORMAL" if ch["state"] == "CHANNELD_NORMAL"
else ChannelState.PENDING else (
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD") ChannelState.PENDING
else ChannelState.CLOSED if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
if ch["state"] else (
in ( ChannelState.CLOSED
"CHANNELD_CLOSING", if ch["state"]
"CLOSINGD_COMPLETE", in (
"CLOSINGD_SIGEXCHANGE", "CHANNELD_CLOSING",
"ONCHAIN", "CLOSINGD_COMPLETE",
"CLOSINGD_SIGEXCHANGE",
"ONCHAIN",
)
else ChannelState.INACTIVE
)
) )
else ChannelState.INACTIVE
), ),
) )
for ch in funds["channels"] for ch in funds["channels"]
@@ -312,8 +316,12 @@ class CoreLightningNode(Node):
NodeInvoice( NodeInvoice(
bolt11=invoice.get("bolt11") or invoice.get("bolt12"), bolt11=invoice.get("bolt11") or invoice.get("bolt12"),
amount=( amount=(
# normal invoice
invoice.get("amount_msat") invoice.get("amount_msat")
# keysend or paid amountless invoice
or invoice.get("amount_received_msat") or invoice.get("amount_received_msat")
# unpaid amountless invoice
or 0
), ),
preimage=invoice.get("payment_preimage"), preimage=invoice.get("payment_preimage"),
memo=invoice.get("description"), memo=invoice.get("description"),
+13 -9
View File
@@ -237,9 +237,11 @@ class LndRestNode(Node):
remote_msat=msat(channel["remote_balance"]), remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]), total_msat=msat(channel["capacity"]),
), ),
state=ChannelState.ACTIVE state=(
if channel["active"] ChannelState.ACTIVE
else ChannelState.INACTIVE, if channel["active"]
else ChannelState.INACTIVE
),
# name=channel['peer_alias'], # name=channel['peer_alias'],
name=info.alias, name=info.alias,
color=info.color, color=info.color,
@@ -318,11 +320,13 @@ class LndRestNode(Node):
amount=payment["value_msat"], amount=payment["value_msat"],
fee=payment["fee_msat"], fee=payment["fee_msat"],
time=payment["creation_date"], time=payment["creation_date"],
destination=await self.get_peer_info( destination=(
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"] await self.get_peer_info(
) payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
if payment["htlcs"] )
else None, if payment["htlcs"]
else None
),
bolt11=payment["payment_request"], bolt11=payment["payment_request"],
preimage=payment["payment_preimage"], preimage=payment["payment_preimage"],
) )
@@ -369,7 +373,7 @@ class LndRestNode(Node):
memo=invoice["memo"], memo=invoice["memo"],
pending=invoice["state"] == "OPEN", pending=invoice["state"] == "OPEN",
paid_at=invoice["settle_date"], paid_at=invoice["settle_date"],
expiry=invoice["creation_date"] + invoice["expiry"], expiry=int(invoice["creation_date"]) + int(invoice["expiry"]),
preimage=_decode_bytes(invoice["r_preimage"]), preimage=_decode_bytes(invoice["r_preimage"]),
bolt11=invoice["payment_request"], bolt11=invoice["payment_request"],
) )
+88
View File
@@ -4,6 +4,8 @@ import importlib
import importlib.metadata import importlib.metadata
import inspect import inspect
import json import json
from enum import Enum
from hashlib import sha256
from os import path from os import path
from sqlite3 import Row from sqlite3 import Row
from time import time from time import time
@@ -66,6 +68,16 @@ class InstalledExtensionsSettings(LNbitsSettings):
# list of redirects that extensions want to perform # list of redirects that extensions want to perform
lnbits_extensions_redirects: List[Any] = Field(default=[]) lnbits_extensions_redirects: List[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
None,
)
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
path = settings.extension_upgrade_path(ext_id)
return path.split("/")[0] if path else None
class ThemesSettings(LNbitsSettings): class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits") lnbits_site_title: str = Field(default="LNbits")
@@ -114,6 +126,9 @@ class SecuritySettings(LNbitsSettings):
lnbits_notifications: bool = Field(default=False) lnbits_notifications: bool = Field(default=False)
lnbits_killswitch: bool = Field(default=False) lnbits_killswitch: bool = Field(default=False)
lnbits_killswitch_interval: int = Field(default=60) lnbits_killswitch_interval: int = Field(default=60)
lnbits_wallet_limit_max_balance: int = Field(default=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
lnbits_watchdog: bool = Field(default=False) lnbits_watchdog: bool = Field(default=False)
lnbits_watchdog_interval: int = Field(default=60) lnbits_watchdog_interval: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000) lnbits_watchdog_delta: int = Field(default=1_000_000)
@@ -123,6 +138,13 @@ class SecuritySettings(LNbitsSettings):
) )
) )
def is_wallet_max_balance_exceeded(self, amount):
return (
self.lnbits_wallet_limit_max_balance
and self.lnbits_wallet_limit_max_balance > 0
and amount > self.lnbits_wallet_limit_max_balance
)
class FakeWalletFundingSource(LNbitsSettings): class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1") fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -160,6 +182,7 @@ class LndRestFundingSource(LNbitsSettings):
lnd_rest_cert: Optional[str] = Field(default=None) lnd_rest_cert: Optional[str] = Field(default=None)
lnd_rest_macaroon: Optional[str] = Field(default=None) lnd_rest_macaroon: Optional[str] = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None) lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True)
lnd_cert: Optional[str] = Field(default=None) lnd_cert: Optional[str] = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None) lnd_admin_macaroon: Optional[str] = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None) lnd_invoice_macaroon: Optional[str] = Field(default=None)
@@ -184,6 +207,11 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None) lnpay_admin_key: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/") alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
alby_access_token: Optional[str] = Field(default=None) alby_access_token: Optional[str] = Field(default=None)
@@ -223,6 +251,7 @@ class FundingSourcesSettings(
LndGrpcFundingSource, LndGrpcFundingSource,
LnPayFundingSource, LnPayFundingSource,
AlbyFundingSource, AlbyFundingSource,
ZBDFundingSource,
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
LnTipsFundingSource, LnTipsFundingSource,
@@ -245,6 +274,44 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False) lnbits_node_ui_transactions: bool = Field(default=False)
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password"
google_auth = "google-auth"
github_auth = "github-auth"
keycloak_auth = "keycloak-auth"
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: List[str] = Field(
default=[
AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value,
]
)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
class GoogleAuthSettings(LNbitsSettings):
google_client_id: str = Field(default="")
google_client_secret: str = Field(default="")
class GitHubAuthSettings(LNbitsSettings):
github_client_id: str = Field(default="")
github_client_secret: str = Field(default="")
class KeycloakAuthSettings(LNbitsSettings):
keycloak_discovery_url: str = Field(default="")
keycloak_client_id: str = Field(default="")
keycloak_client_secret: str = Field(default="")
class EditableSettings( class EditableSettings(
UsersSettings, UsersSettings,
ExtensionsSettings, ExtensionsSettings,
@@ -255,6 +322,10 @@ class EditableSettings(
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
AuthSettings,
GoogleAuthSettings,
GitHubAuthSettings,
KeycloakAuthSettings,
): ):
@validator( @validator(
"lnbits_admin_users", "lnbits_admin_users",
@@ -288,6 +359,7 @@ class UpdateSettings(EditableSettings):
class EnvSettings(LNbitsSettings): class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False) debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
host: str = Field(default="127.0.0.1") host: str = Field(default="127.0.0.1")
port: int = Field(default=5000) port: int = Field(default=5000)
@@ -296,12 +368,15 @@ class EnvSettings(LNbitsSettings):
lnbits_path: str = Field(default=".") lnbits_path: str = Field(default=".")
lnbits_extensions_path: str = Field(default="lnbits") lnbits_extensions_path: str = Field(default="lnbits")
super_user: str = Field(default="") super_user: str = Field(default="")
auth_secret_key: str = Field(default="")
version: str = Field(default="0.0.0") version: str = Field(default="0.0.0")
user_agent: str = Field(default="") user_agent: str = Field(default="")
enable_log_to_file: bool = Field(default=True) enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB") log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months") log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time()) server_startup_time: int = Field(default=time())
lnbits_extensions_deactivate_all: bool = Field(default=False)
cleanup_wallets_days: int = Field(default=90)
@property @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
@@ -332,6 +407,7 @@ class SuperUserSettings(LNbitsSettings):
"LnTipsWallet", "LnTipsWallet",
"LNPayWallet", "LNPayWallet",
"AlbyWallet", "AlbyWallet",
"ZBDWallet",
"LNbitsWallet", "LNbitsWallet",
"OpenNodeWallet", "OpenNodeWallet",
] ]
@@ -344,6 +420,7 @@ class TransientSettings(InstalledExtensionsSettings):
# - are not read from a file or from the `settings` table # - are not read from a file or from the `settings` table
# - are not persisted in the `settings` table when the settings are updated # - are not persisted in the `settings` table when the settings are updated
# - are cleared on server restart # - are cleared on server restart
first_install: bool = Field(default=False)
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
@@ -384,6 +461,14 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
case_sensitive = False case_sensitive = False
json_loads = list_parse_fallback json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str):
return (
len(self.lnbits_allowed_users) == 0
or user_id in self.lnbits_allowed_users
or user_id in self.lnbits_admin_users
or user_id == self.super_user
)
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):
super_user: str super_user: str
@@ -432,6 +517,9 @@ settings = Settings()
settings.lnbits_path = str(path.dirname(path.realpath(__file__))) settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
settings.version = importlib.metadata.version("lnbits") settings.version = importlib.metadata.version("lnbits")
settings.auth_secret_key = (
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
)
if not settings.user_agent: if not settings.user_agent:
settings.user_agent = f"LNbits/{settings.version}" settings.user_agent = f"LNbits/{settings.version}"
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -550,3 +550,7 @@ video {
padding: 0.2rem; padding: 0.2rem;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
.whitespace-pre-line {
white-space: pre-line;
}
+48 -2
View File
@@ -36,7 +36,7 @@ window.localisation.br = {
name_your_wallet: 'Nomeie sua carteira %{name}', name_your_wallet: 'Nomeie sua carteira %{name}',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *', paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description: lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.', 'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, ZBD, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
export_to_phone: 'Exportar para o telefone com código QR', export_to_phone: 'Exportar para o telefone com código QR',
export_to_phone_desc: export_to_phone_desc:
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.', 'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
@@ -61,8 +61,10 @@ window.localisation.br = {
service_fee_tooltip: service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída', 'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro', toggle_darkmode: 'Alternar modo escuro',
payment_reactions: 'Reações de Pagamento',
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger', view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
api_docs: 'Documentação da API', api_docs: 'Documentação da API',
api_keys_api_docs: 'URL do Node, chaves da API e documentação da API',
lnbits_version: 'Versão do LNbits', lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em', runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta', credit_hint: 'Pressione Enter para creditar a conta',
@@ -177,6 +179,7 @@ window.localisation.br = {
notification_source_label: notification_source_label:
'URL de origem (use apenas a fonte de status oficial do LNbits e fontes de confiança)', 'URL de origem (use apenas a fonte de status oficial do LNbits e fontes de confiança)',
more: 'mais', more: 'mais',
less: 'menos',
releases: 'Lançamentos', releases: 'Lançamentos',
killswitch: 'Dispositivo de desativação', killswitch: 'Dispositivo de desativação',
watchdog: 'Cão de guarda', watchdog: 'Cão de guarda',
@@ -188,6 +191,12 @@ window.localisation.br = {
allow_access_hint: 'Permitir acesso por IP (substituirá os IPs bloqueados)', allow_access_hint: 'Permitir acesso por IP (substituirá os IPs bloqueados)',
enter_ip: 'Digite o IP e pressione enter', enter_ip: 'Digite o IP e pressione enter',
rate_limiter: 'Limitador de Taxa', rate_limiter: 'Limitador de Taxa',
wallet_limiter: 'Limitador de Carteira',
wallet_limit_max_withdraw_per_day:
'Retirada máxima diária da carteira em sats (0 para desativar)',
wallet_max_ballance: 'Saldo máximo da carteira em sats (0 para desativar)',
wallet_limit_secs_between_trans:
'Minutos e segundos entre transações por carteira (0 para desativar)',
number_of_requests: 'Número de solicitações', number_of_requests: 'Número de solicitações',
time_unit: 'Unidade de tempo', time_unit: 'Unidade de tempo',
minute: 'minuto', minute: 'minuto',
@@ -195,5 +204,42 @@ window.localisation.br = {
hour: 'hora', hour: 'hora',
disable_server_log: 'Desativar Log do Servidor', disable_server_log: 'Desativar Log do Servidor',
enable_server_log: 'Ativar Registro do Servidor', enable_server_log: 'Ativar Registro do Servidor',
coming_soon: 'Funcionalidade em breve' coming_soon: 'Funcionalidade em breve',
session_has_expired: 'Sua sessão expirou. Por favor, faça login novamente.',
instant_access_question: 'Quer acesso imediato?',
login_with_user_id: 'Faça login com ID do usuário',
or: 'ou',
create_new_wallet: 'Criar Nova Carteira',
login_to_account: 'Faça login na sua conta',
create_account: 'Criar conta',
account_settings: 'Configurações da Conta',
signin_with_google: 'Entrar com o Google',
signin_with_github: 'Entrar com GitHub',
signin_with_keycloak: 'Entrar com Keycloak',
username_or_email: 'Nome de usuário ou E-mail',
password: 'Senha',
password_config: 'Configuração de Senha',
password_repeat: 'Repetição de senha',
change_password: 'Alterar Senha',
set_password: 'Definir Senha',
invalid_password: 'A senha deve ter pelo menos 8 caracteres',
login: 'Entrar',
register: 'Registrar',
username: 'Nome de usuário',
user_id: 'ID do Usuário',
email: 'E-mail',
first_name: 'Primeiro Nome',
last_name: 'Sobrenome',
picture: 'Foto',
verify_email: 'Verifique o e-mail com',
account: 'Conta',
update_account: 'Atualizar Conta',
invalid_username: 'Nome de usuário inválido',
auth_provider: 'Provedor de Autenticação',
my_account: 'Minha Conta',
back: 'Voltar',
logout: 'Sair',
look_and_feel: 'Aparência',
language: 'Idioma',
color_scheme: 'Esquema de Cores'
} }
+47 -2
View File
@@ -35,7 +35,7 @@ window.localisation.cn = {
name_your_wallet: '给你的 %{name}钱包起个名字', name_your_wallet: '给你的 %{name}钱包起个名字',
paste_invoice_label: '粘贴发票,付款请求或lnurl*', paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description: lnbits_description:
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。', 'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, ZBD, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
export_to_phone: '通过二维码导出到手机', export_to_phone: '通过二维码导出到手机',
export_to_phone_desc: export_to_phone_desc:
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。', '这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
@@ -57,8 +57,10 @@ window.localisation.cn = {
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats', service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats',
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费', service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
toggle_darkmode: '切换暗黑模式', toggle_darkmode: '切换暗黑模式',
payment_reactions: '支付反应',
view_swagger_docs: '查看 LNbits Swagger API 文档', view_swagger_docs: '查看 LNbits Swagger API 文档',
api_docs: 'API文档', api_docs: 'API文档',
api_keys_api_docs: '节点URL、API密钥和API文档',
lnbits_version: 'LNbits版本', lnbits_version: 'LNbits版本',
runs_on: '可运行在', runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户', credit_hint: '按 Enter 键充值账户',
@@ -166,6 +168,7 @@ window.localisation.cn = {
notification_source: '通知来源', notification_source: '通知来源',
notification_source_label: '来源 URL(仅使用官方LNbits状态源和您信任的源)', notification_source_label: '来源 URL(仅使用官方LNbits状态源和您信任的源)',
more: '更多', more: '更多',
less: '少',
releases: '版本', releases: '版本',
killswitch: '杀手锏', killswitch: '杀手锏',
watchdog: '监控程序', watchdog: '监控程序',
@@ -177,6 +180,11 @@ window.localisation.cn = {
allow_access_hint: '允许通过IP访问(将覆盖被屏蔽的IP)', allow_access_hint: '允许通过IP访问(将覆盖被屏蔽的IP)',
enter_ip: '输入IP地址并按回车键', enter_ip: '输入IP地址并按回车键',
rate_limiter: '速率限制器', rate_limiter: '速率限制器',
wallet_limiter: '钱包限制器',
wallet_limit_max_withdraw_per_day:
'每日钱包最大提现额度(单位:sats)(设为0则禁用)',
wallet_max_ballance: '钱包最大余额(以sats计)(设为0则禁用)',
wallet_limit_secs_between_trans: '每个钱包交易间最少秒数(设为0则禁用)',
number_of_requests: '请求次数', number_of_requests: '请求次数',
time_unit: '时间单位', time_unit: '时间单位',
minute: '分钟', minute: '分钟',
@@ -184,5 +192,42 @@ window.localisation.cn = {
hour: '小时', hour: '小时',
disable_server_log: '禁用服务器日志', disable_server_log: '禁用服务器日志',
enable_server_log: '启用服务器日志', enable_server_log: '启用服务器日志',
coming_soon: '功能即将推出' coming_soon: '功能即将推出',
session_has_expired: '您的会话已过期。请重新登录。',
instant_access_question: '想要即时访问吗?',
login_with_user_id: '使用用户ID登录',
or: '或',
create_new_wallet: '创建新钱包',
login_to_account: '登录您的账户',
create_account: '创建账户',
account_settings: '账户设置',
signin_with_google: '使用谷歌账号登录',
signin_with_github: '使用GitHub登录',
signin_with_keycloak: '使用Keycloak登录',
username_or_email: '用户名或电子邮箱',
password: '密码',
password_config: '密码配置',
password_repeat: '密码重复',
change_password: '修改密码',
set_password: '设置密码',
invalid_password: '密码至少需要有8个字符',
login: '登录',
register: '注册',
username: '用户名',
user_id: '用户ID',
email: '电子邮件',
first_name: '名字',
last_name: '姓氏',
picture: '图片',
verify_email: '验证电子邮件与',
account: '账户',
update_account: '更新帐户',
invalid_username: '无效用户名',
auth_provider: '认证提供者',
my_account: '我的账户',
back: '返回',
logout: '注销',
look_and_feel: '外观和感觉',
language: '语言',
color_scheme: '配色方案'
} }
+48 -2
View File
@@ -35,7 +35,7 @@ window.localisation.cs = {
name_your_wallet: 'Pojmenujte svou %{name} peněženku', name_your_wallet: 'Pojmenujte svou %{name} peněženku',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *', paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description: lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.', 'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, ZBD, LNPay a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
export_to_phone: 'Exportovat do telefonu pomocí QR kódu', export_to_phone: 'Exportovat do telefonu pomocí QR kódu',
export_to_phone_desc: export_to_phone_desc:
'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.', 'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.',
@@ -61,8 +61,10 @@ window.localisation.cs = {
service_fee_tooltip: service_fee_tooltip:
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci', 'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
toggle_darkmode: 'Přepnout tmavý režim', toggle_darkmode: 'Přepnout tmavý režim',
payment_reactions: 'Reakce na platby',
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci', view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
api_docs: 'API dokumentace', api_docs: 'API dokumentace',
api_keys_api_docs: 'Adresa uzlu, API klíče a API dokumentace',
lnbits_version: 'Verze LNbits', lnbits_version: 'Verze LNbits',
runs_on: 'Běží na', runs_on: 'Běží na',
credit_hint: 'Stiskněte Enter pro připsání na účet', credit_hint: 'Stiskněte Enter pro připsání na účet',
@@ -174,6 +176,7 @@ window.localisation.cs = {
notification_source_label: notification_source_label:
'URL zdroje (používejte pouze oficiální zdroj stavu LNbits a zdroje, kterým můžete věřit)', 'URL zdroje (používejte pouze oficiální zdroj stavu LNbits a zdroje, kterým můžete věřit)',
more: 'více', more: 'více',
less: 'méně',
releases: 'Vydání', releases: 'Vydání',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Watchdog', watchdog: 'Watchdog',
@@ -185,6 +188,12 @@ window.localisation.cs = {
allow_access_hint: 'Povolit přístup podle IP (přepíše blokované IP)', allow_access_hint: 'Povolit přístup podle IP (přepíše blokované IP)',
enter_ip: 'Zadejte IP a stiskněte enter', enter_ip: 'Zadejte IP a stiskněte enter',
rate_limiter: 'Omezovač počtu požadavků', rate_limiter: 'Omezovač počtu požadavků',
wallet_limiter: 'Omezení peněženky',
wallet_limit_max_withdraw_per_day:
'Maximální denní limit pro výběr z peněženky v sats (0 pro deaktivaci)',
wallet_max_ballance: 'Maximální zůstatek v peněžence v sats (0 pro zakázání)',
wallet_limit_secs_between_trans:
'Minimální počet sekund mezi transakcemi na peněženku (0 pro vypnutí)',
number_of_requests: 'Počet požadavků', number_of_requests: 'Počet požadavků',
time_unit: 'Časová jednotka', time_unit: 'Časová jednotka',
minute: 'minuta', minute: 'minuta',
@@ -192,5 +201,42 @@ window.localisation.cs = {
hour: 'hodina', hour: 'hodina',
disable_server_log: 'Zakázat log serveru', disable_server_log: 'Zakázat log serveru',
enable_server_log: 'Povolit log serveru', enable_server_log: 'Povolit log serveru',
coming_soon: 'Funkce brzy dostupná' coming_soon: 'Funkce brzy dostupná',
session_has_expired: 'Vaše relace vypršela. Prosím, přihlašte se znovu.',
instant_access_question: 'Chcete okamžitý přístup?',
login_with_user_id: 'Přihlásit se s uživatelským ID',
or: 'nebo',
create_new_wallet: 'Vytvořit novou peněženku',
login_to_account: 'Přihlaste se ke svému účtu',
create_account: 'Vytvořit účet',
account_settings: 'Nastavení účtu',
signin_with_google: 'Přihlásit se přes Google',
signin_with_github: 'Přihlásit se přes GitHub',
signin_with_keycloak: 'Přihlásit se přes Keycloak',
username_or_email: 'Uživatelské jméno nebo Email',
password: 'Heslo',
password_config: 'Konfigurace hesla',
password_repeat: 'Opakujte heslo',
change_password: 'Změnit heslo',
set_password: 'Nastavit heslo',
invalid_password: 'Heslo musí mít alespoň 8 znaků',
login: 'Přihlášení',
register: 'Registrovat',
username: 'Uživatelské jméno',
user_id: 'ID uživatele',
email: 'Email',
first_name: 'Křestní jméno',
last_name: 'Příjmení',
picture: 'Obrázek',
verify_email: 'Ověřte e-mail s',
account: 'Účet',
update_account: 'Aktualizovat účet',
invalid_username: 'Neplatné uživatelské jméno',
auth_provider: 'Poskytovatel ověření',
my_account: 'Můj účet',
back: 'Zpět',
logout: 'Odhlásit se',
look_and_feel: 'Vzhled a chování',
language: 'Jazyk',
color_scheme: 'Barevné schéma'
} }
+52 -4
View File
@@ -37,7 +37,7 @@ window.localisation.de = {
paste_invoice_label: paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *', 'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description: lnbits_description:
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.', 'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, ZBD, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
export_to_phone: 'Auf dem Telefon öffnen', export_to_phone: 'Auf dem Telefon öffnen',
export_to_phone_desc: export_to_phone_desc:
'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.', 'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.',
@@ -63,8 +63,10 @@ window.localisation.de = {
service_fee_tooltip: service_fee_tooltip:
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird', 'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
toggle_darkmode: 'Auf Dark Mode umschalten', toggle_darkmode: 'Auf Dark Mode umschalten',
view_swagger_docs: 'LNbits Swagger API-Dokumente', payment_reactions: 'Zahlungsreaktionen',
api_docs: 'API docs', view_swagger_docs: 'LNbits Swagger API-Dokumentation',
api_docs: 'API-Dokumentation',
api_keys_api_docs: 'Knoten-URL, API-Schlüssel und API-Dokumentation',
lnbits_version: 'LNbits-Version', lnbits_version: 'LNbits-Version',
runs_on: 'Läuft auf', runs_on: 'Läuft auf',
credit_hint: 'Klicke Enter, um das Konto zu belasten', credit_hint: 'Klicke Enter, um das Konto zu belasten',
@@ -179,6 +181,7 @@ window.localisation.de = {
notification_source_label: notification_source_label:
'Quell-URL (verwenden Sie nur die offizielle LNbits-Statusquelle und Quellen, denen Sie vertrauen können)', 'Quell-URL (verwenden Sie nur die offizielle LNbits-Statusquelle und Quellen, denen Sie vertrauen können)',
more: 'mehr', more: 'mehr',
less: 'weniger',
releases: 'Veröffentlichungen', releases: 'Veröffentlichungen',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Wachhund', watchdog: 'Wachhund',
@@ -190,6 +193,13 @@ window.localisation.de = {
allow_access_hint: 'Zugriff durch IP erlauben (überschreibt blockierte IPs)', allow_access_hint: 'Zugriff durch IP erlauben (überschreibt blockierte IPs)',
enter_ip: 'Geben Sie die IP ein und drücken Sie die Eingabetaste', enter_ip: 'Geben Sie die IP ein und drücken Sie die Eingabetaste',
rate_limiter: 'Ratenbegrenzer', rate_limiter: 'Ratenbegrenzer',
wallet_limiter: 'Geldbeutel-Limiter',
wallet_limit_max_withdraw_per_day:
'Maximales tägliches Wallet-Auszahlungslimit in Sats (0 zum Deaktivieren)',
wallet_max_ballance:
'Maximales Guthaben der Wallet in Sats (0 zum Deaktivieren)',
wallet_limit_secs_between_trans:
'Mindestsekunden zwischen Transaktionen pro Wallet (0 zum Deaktivieren)',
number_of_requests: 'Anzahl der Anfragen', number_of_requests: 'Anzahl der Anfragen',
time_unit: 'Zeiteinheit', time_unit: 'Zeiteinheit',
minute: 'Minute', minute: 'Minute',
@@ -197,5 +207,43 @@ window.localisation.de = {
hour: 'Stunde', hour: 'Stunde',
disable_server_log: 'Server-Log deaktivieren', disable_server_log: 'Server-Log deaktivieren',
enable_server_log: 'Serverprotokollierung aktivieren', enable_server_log: 'Serverprotokollierung aktivieren',
coming_soon: 'Funktion demnächst verfügbar' coming_soon: 'Funktion demnächst verfügbar',
session_has_expired:
'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
instant_access_question: 'Möchten Sie sofortigen Zugang?',
login_with_user_id: 'Mit Benutzer-ID anmelden',
or: 'oder',
create_new_wallet: 'Neue Geldbörse erstellen',
login_to_account: 'Melden Sie sich bei Ihrem Konto an',
create_account: 'Konto erstellen',
account_settings: 'Kontoeinstellungen',
signin_with_google: 'Mit Google anmelden',
signin_with_github: 'Anmelden mit GitHub',
signin_with_keycloak: 'Mit Keycloak anmelden',
username_or_email: 'Benutzername oder E-Mail',
password: 'Passwort',
password_config: 'Passwortkonfiguration',
password_repeat: 'Passwortwiederholung',
change_password: 'Passwort ändern',
set_password: 'Passwort festlegen',
invalid_password: 'Das Passwort muss mindestens 8 Zeichen haben.',
login: 'Anmelden',
register: 'Registrieren',
username: 'Benutzername',
user_id: 'Benutzer-ID',
email: 'E-Mail',
first_name: 'Vorname',
last_name: 'Nachname',
picture: 'Bild',
verify_email: 'E-Mail verifizieren mit',
account: 'Konto',
update_account: 'Konto aktualisieren',
invalid_username: 'Ungültiger Benutzername',
auth_provider: 'Anbieter für Authentifizierung',
my_account: 'Mein Konto',
back: 'Zurück',
logout: 'Abmelden',
look_and_feel: 'Aussehen und Verhalten',
language: 'Sprache',
color_scheme: 'Farbschema'
} }
+61 -4
View File
@@ -35,7 +35,7 @@ window.localisation.en = {
name_your_wallet: 'Name your %{name} wallet', name_your_wallet: 'Name your %{name} wallet',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *', paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
lnbits_description: lnbits_description:
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.', 'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, ZBD, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
export_to_phone: 'Export to Phone with QR Code', export_to_phone: 'Export to Phone with QR Code',
export_to_phone_desc: export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.', 'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
@@ -59,8 +59,10 @@ window.localisation.en = {
service_fee_tooltip: service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction', 'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode', toggle_darkmode: 'Toggle Dark Mode',
payment_reactions: 'Payment Reactions',
view_swagger_docs: 'View LNbits Swagger API docs', view_swagger_docs: 'View LNbits Swagger API docs',
api_docs: 'API docs', api_docs: 'API docs',
api_keys_api_docs: 'Node URL, API keys and API docs',
lnbits_version: 'LNbits version', lnbits_version: 'LNbits version',
runs_on: 'Runs on', runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account', credit_hint: 'Press Enter to credit account',
@@ -95,8 +97,12 @@ window.localisation.en = {
'This is an LNURL-withdraw QR code for slurping everything from this wallet. Do not share with anyone. It is compatible with balanceCheck and balanceNotify so your wallet may keep pulling the funds continuously from here after the first withdraw.', 'This is an LNURL-withdraw QR code for slurping everything from this wallet. Do not share with anyone. It is compatible with balanceCheck and balanceNotify so your wallet may keep pulling the funds continuously from here after the first withdraw.',
i_understand: 'I understand', i_understand: 'I understand',
copy_wallet_url: 'Copy wallet URL', copy_wallet_url: 'Copy wallet URL',
disclaimer_dialog: disclaimer_dialog_title: 'Important!',
'Login functionality to be released in a future update, for now, make sure you bookmark this page for future access to your wallet! This service is in BETA, and we hold no responsibility for people losing access to funds.', disclaimer_dialog: `You *must* save your login credentials to be able to access your wallet again.If you lose them, you will lose access to your wallet and funds.
Find your login credentials on your account settings page.
This service is in BETA. LNbits holds no responsibility for loss of access to funds.`,
no_transactions: 'No transactions made yet', no_transactions: 'No transactions made yet',
manage: 'Manage', manage: 'Manage',
extensions: 'Extensions', extensions: 'Extensions',
@@ -171,6 +177,7 @@ window.localisation.en = {
notification_source_label: notification_source_label:
'Source URL (only use the official LNbits status source, and sources you can trust)', 'Source URL (only use the official LNbits status source, and sources you can trust)',
more: 'more', more: 'more',
less: 'less',
releases: 'Releases', releases: 'Releases',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Watchdog', watchdog: 'Watchdog',
@@ -182,6 +189,12 @@ window.localisation.en = {
allow_access_hint: 'Allow access by IP (will override blocked IPs)', allow_access_hint: 'Allow access by IP (will override blocked IPs)',
enter_ip: 'Enter IP and hit enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Wallet Limiter',
wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 to disable)',
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
wallet_limit_secs_between_trans:
'Min secs between transactions per wallet (0 to disable)',
number_of_requests: 'Number of requests', number_of_requests: 'Number of requests',
time_unit: 'Time unit', time_unit: 'Time unit',
minute: 'minute', minute: 'minute',
@@ -189,5 +202,49 @@ window.localisation.en = {
hour: 'hour', hour: 'hour',
disable_server_log: 'Disable Server Log', disable_server_log: 'Disable Server Log',
enable_server_log: 'Enable Server Log', enable_server_log: 'Enable Server Log',
coming_soon: 'Feature coming soon' coming_soon: 'Feature coming soon',
session_has_expired: 'Your session has expired. Please login again.',
instant_access_question: 'Want instant access?',
login_with_user_id: 'Login with user ID',
or: 'or',
create_new_wallet: 'Create New Wallet',
login_to_account: 'Login to your account',
create_account: 'Create account',
account_settings: 'Account Settings',
signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak',
username_or_email: 'Username or Email',
password: 'Password',
password_config: 'Password Config',
password_repeat: 'Password repeat',
change_password: 'Change Password',
set_password: 'Set Password',
invalid_password: 'Password must have at least 8 characters',
login: 'Login',
register: 'Register',
username: 'Username',
user_id: 'User ID',
email: 'Email',
first_name: 'First Name',
last_name: 'Last Name',
picture: 'Picture',
verify_email: 'Verify email with',
account: 'Account',
update_account: 'Update Account',
invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider',
my_account: 'My Account',
back: 'Back',
logout: 'Logout',
look_and_feel: 'Look and Feel',
language: 'Language',
color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
show_qr: 'Show QR',
retry_install: 'Retry Install',
new_payment: 'Make New Payment'
} }
+52 -4
View File
@@ -36,7 +36,7 @@ window.localisation.es = {
name_your_wallet: 'Nombre de su billetera %{name}', name_your_wallet: 'Nombre de su billetera %{name}',
paste_invoice_label: 'Pegue la factura aquí', paste_invoice_label: 'Pegue la factura aquí',
lnbits_description: lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.', 'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, ZBD, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
export_to_phone: 'Exportar a teléfono con código QR', export_to_phone: 'Exportar a teléfono con código QR',
export_to_phone_desc: export_to_phone_desc:
'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.', 'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.',
@@ -61,8 +61,10 @@ window.localisation.es = {
service_fee_tooltip: service_fee_tooltip:
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente', 'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
toggle_darkmode: 'Cambiar modo oscuro', toggle_darkmode: 'Cambiar modo oscuro',
view_swagger_docs: 'Ver documentos de API de LNbits Swagger', payment_reactions: 'Reacciones de Pago',
api_docs: 'Documentos de API', view_swagger_docs: 'Ver documentación de API de LNbits Swagger',
api_docs: 'Documentación de API',
api_keys_api_docs: 'URL del nodo, claves de API y documentación de API',
lnbits_version: 'Versión de LNbits', lnbits_version: 'Versión de LNbits',
runs_on: 'Corre en', runs_on: 'Corre en',
credit_hint: 'Presione Enter para cargar la cuenta', credit_hint: 'Presione Enter para cargar la cuenta',
@@ -177,6 +179,7 @@ window.localisation.es = {
notification_source_label: notification_source_label:
'URL de origen (solo use la fuente oficial de estado de LNbits y fuentes en las que confíe)', 'URL de origen (solo use la fuente oficial de estado de LNbits y fuentes en las que confíe)',
more: 'más', more: 'más',
less: 'menos',
releases: 'Lanzamientos', releases: 'Lanzamientos',
killswitch: 'Interruptor de apagado', killswitch: 'Interruptor de apagado',
watchdog: 'Perro guardián', watchdog: 'Perro guardián',
@@ -188,6 +191,13 @@ window.localisation.es = {
allow_access_hint: 'Permitir acceso por IP (anulará las IPs bloqueadas)', allow_access_hint: 'Permitir acceso por IP (anulará las IPs bloqueadas)',
enter_ip: 'Ingrese la IP y presione enter', enter_ip: 'Ingrese la IP y presione enter',
rate_limiter: 'Limitador de tasa', rate_limiter: 'Limitador de tasa',
wallet_limiter: 'Limitador de Cartera',
wallet_limit_max_withdraw_per_day:
'Límite diario de retiro de la cartera en sats (0 para deshabilitar)',
wallet_max_ballance:
'Saldo máximo de la billetera en sats (0 para desactivar)',
wallet_limit_secs_between_trans:
'Mín. segs entre transacciones por cartera (0 para desactivar)',
number_of_requests: 'Número de solicitudes', number_of_requests: 'Número de solicitudes',
time_unit: 'Unidad de tiempo', time_unit: 'Unidad de tiempo',
minute: 'minuto', minute: 'minuto',
@@ -195,5 +205,43 @@ window.localisation.es = {
hour: 'hora', hour: 'hora',
disable_server_log: 'Desactivar registro del servidor', disable_server_log: 'Desactivar registro del servidor',
enable_server_log: 'Activar registro del servidor', enable_server_log: 'Activar registro del servidor',
coming_soon: 'Función próximamente disponible' coming_soon: 'Función próximamente disponible',
session_has_expired:
'Tu sesión ha expirado. Por favor, inicia sesión de nuevo.',
instant_access_question: '¿Quieres acceso instantáneo?',
login_with_user_id: 'Iniciar sesión con ID de usuario',
or: 'o',
create_new_wallet: 'Crear Nueva Cartera',
login_to_account: 'Inicie sesión en su cuenta',
create_account: 'Crear cuenta',
account_settings: 'Configuración de la cuenta',
signin_with_google: 'Inicia sesión con Google',
signin_with_github: 'Inicia sesión con GitHub',
signin_with_keycloak: 'Iniciar sesión con Keycloak',
username_or_email: 'Nombre de usuario o correo electrónico',
password: 'Contraseña',
password_config: 'Configuración de Contraseña',
password_repeat: 'Repetición de contraseña',
change_password: 'Cambiar contraseña',
set_password: 'Establecer contraseña',
invalid_password: 'La contraseña debe tener al menos 8 caracteres.',
login: 'Iniciar sesión',
register: 'Registrarse',
username: 'Nombre de usuario',
user_id: 'Identificación de usuario',
email: 'Correo electrónico',
first_name: 'Nombre de pila',
last_name: 'Apellido',
picture: 'Imagen',
verify_email: 'Verifique el correo electrónico con',
account: 'Cuenta',
update_account: 'Actualizar cuenta',
invalid_username: 'Nombre de usuario inválido',
auth_provider: 'Proveedor de Autenticación',
my_account: 'Mi cuenta',
back: 'Atrás',
logout: 'Cerrar sesión',
look_and_feel: 'Apariencia',
language: 'Idioma',
color_scheme: 'Esquema de colores'
} }
+245
View File
@@ -0,0 +1,245 @@
window.localisation.fi = {
confirm: 'Kyllä',
server: 'Palvelin',
theme: 'Teema',
funding: 'Rahoitus',
users: 'Käyttäjät',
apps: 'Sovellukset',
channels: 'Kanavat',
transactions: 'Tapahtumat',
dashboard: 'Ohjauspaneeli',
node: 'Solmu',
total_capacity: 'Kokonaiskapasiteetti',
avg_channel_size: 'Keskimääräisen kanavan kapasiteetti',
biggest_channel_size: 'Suurimman kanavan kapasiteetti',
smallest_channel_size: 'Pienimmän kanavan kapasiteetti',
number_of_channels: 'Kanavien lukumäärä',
active_channels: 'Aktiivisia kanavia',
connect_peer: 'Yhdistä naapuriin',
connect: 'Yhdistä',
open_channel: 'Avaa kanava',
open: 'Avaa',
close_channel: 'Sulje kanava',
close: 'Sulje',
restart: 'Palvelimen uudelleen käynnistys',
save: 'Tallenna',
save_tooltip: 'Tallenna muutokset',
topup: 'Topup',
topup_wallet: 'Lisää varoja lompakkoon',
topup_hint: 'Lisää varoja lompakkoon sen ID:n perusteella',
restart_tooltip: 'Uudelleenkäynnistä palvelu muutosten käyttöönottamiseksi',
add_funds_tooltip: 'Lisää varoja lompakkoon',
reset_defaults: 'Peruuta muutokset',
reset_defaults_tooltip:
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio',
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description:
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, kuten LND, Core Lightning, OpenNode, Alby, LNPay ja jopa itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
export_to_phone: 'Käytä puhelimessa lukemalla QR-koodi',
export_to_phone_desc:
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voi lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
wallets: 'Lompakot',
add_wallet: 'Lisää lompakko',
delete_wallet: 'Poista lompakko',
delete_wallet_desc:
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
rename_wallet: 'Nimeä lompakko uudelleen',
update_name: 'Tallenna',
fiat_tracking: 'Käytettävä valuutta',
currency: 'Valuutta',
update_currency: 'Tallenna',
press_to_claim: 'Lunasta varat painamalla tästä',
donate: 'Lahjoita',
view_github: 'Näytä GitHub:ssa',
voidwallet_active:
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
use_with_caution:
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: %{name}',
service_fee: 'Palvelumaksu: %{amount} % tapahtumasta',
service_fee_max:
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
service_fee_tooltip:
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä',
toggle_reactions: 'Käytä tapahtuma efektejä',
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
api_docs: 'API-dokumentaatio',
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
lnbits_version: 'LNbits versio',
runs_on: 'Mukana menossa',
credit_hint: 'Hyväksy painamalla Enter',
credit_label: 'Lisää tilille varoja %{denomination}',
paste: 'Liitä',
paste_from_clipboard: 'Liitä leikepöydältä',
paste_request: 'Liitä pyyntö',
create_invoice: 'Laskuta',
camera_tooltip: 'Kuvaa lasku tai QR-koodi',
export_csv: 'Vie CSV-tiedostoon',
chart_tooltip: 'Näytä kaaviokuva',
pending: 'Odottaa',
copy_invoice: 'Kopioi lasku',
withdraw_from: 'Nosta kohteesta',
cancel: 'Peruuta',
scan: 'Scannaa',
read: 'Lue',
pay: 'Maksa',
memo: 'Kuvaus',
date: 'Päiväys',
processing_payment: 'Maksua käsitellään...',
not_enough_funds: 'Varat eivät riitä!',
search_by_tag_memo_amount: 'Etsi tunnisteella, muistiolla tai määrällä',
invoice_waiting: 'Lasku osottaa maksamista',
payment_received: 'Maksu vastaanotettu',
payment_sent: 'Maksu lähetetty',
receive: 'vastaanota',
send: 'lähetä',
outgoing_payment_pending: 'Lähtevä maksu odottaa',
drain_funds: 'Tyhjennä varat',
drain_funds_desc:
'Tämä LNURL-withdraw -tyyppinen QR-koodi on tarkoitettu kaikkien varojen imurointiin lompakosta. ÄLÄ JAA SITÄ KENELLEKÄÄN! Se on balanceCheck- ja balanceNotify-toimintojen kanssa yhteensopiva, joten sitä voi käyttää lompakon tyhjentämiseen ensimmäisen käytön jälleen jatkuvasti.',
i_understand: 'Vakuutan ymmärtäväni',
copy_wallet_url: 'Kopioi lompakon URL',
disclaimer_dialog:
'Muistathan tallettaa kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossakin kirjautumaan lompakkoosi! Tutustu myös Tilin asetukset -sivuun. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
manage: 'Hallinnointi',
extensions: 'Laajennukset',
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
search_extensions: 'Etsi laajennuksia',
warning: 'Varoitus',
repository: 'Laajennuksien lähde',
confirm_continue: 'Haluatko varmasti jatkaa?',
manage_extension_details: 'Asenna/Poista laajennus',
install: 'Asenna',
uninstall: 'Poista',
drop_db: 'Poista tiedot',
enable: 'Ota käyttöön',
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
disable: 'Poista käytöstä',
installed: 'Asennettu',
activated: 'Käytössä',
deactivated: 'Poissa käytöstä',
release_notes: 'Julkaisutiedot',
activate_extension_details: 'Aseta/Poista laajennus käyttäjien saatavilta',
featured: 'Esittelyssä',
all: 'Kaikki',
only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)',
admin_only: 'Pääkäyttäjille',
new_version: 'Uusi versio',
extension_depends_on: 'Edellyttää:',
extension_rating_soon: 'Arvostelut on tulossa pian',
extension_installed_version: 'Nykyinen versio',
extension_uninstall_warning:
'Olet poistamassa laajennuksen kaikilta käyttäjiltä.',
uninstall_confirm: 'Kyllä, poista asennus',
extension_db_drop_info:
'Kaikki laajennuksen tallettama tieto poistetaan pysyvästi. Poistoa ei voi jälkikäteen peruuttaa!',
extension_db_drop_warning:
'Olet tuhoamassa laajennuksen tallettamat tiedot. Vahvista poisto kirjoittamalla viivalle seuraavassa näkyvä laajennuksen nimi:',
extension_min_lnbits_version: 'Tämä julkaisu vaatii vähintään LNbits-version',
payment_hash: 'Maksun tiiviste',
fee: 'Kulu',
amount: 'Määrä',
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanheneminen',
webhook: 'Webhook',
payment_proof: 'Maksun varmenne',
update_available: 'Saatavilla on päivitys versioon %{version}!',
latest_update:
'Käytössä oleva versio %{version}, on viimeisin saatavilla oleva.',
notifications: 'Tiedotteet',
no_notifications: 'Ei tiedotteita',
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
enable_notifications: 'Ota tiedotteet käyttöön',
enable_notifications_desc:
'Tämän ollessa valittuna, noudetaan LNbits-tilatiedotteet. Niitä ovat esimerkiksi turvallisuuteen liittyvät tapahtumatiedotteet ja tiedot tämän ohjelmiston päivityksistä.',
enable_killswitch: 'Ota Killswitch käyttöön',
enable_killswitch_desc:
'Jos LNbits antaa killswitch-komennon, niin rahoituslähteeksi valitaan automaattisesti heti VoidWallet. Päivityksen jälkeen tämä asetus pitää tarkastaa uudelleen.',
killswitch_interval: 'Killswitch-aikaväli',
killswitch_interval_desc:
'Tällä määritetään kuinka usein taustatoiminto tarkistaa killswitch-signaalin tilatiedotteiden lähteestä. Hakujen väli ilmoitetaan minuutteina.',
enable_watchdog: 'Ota Watchdog käyttöön',
enable_watchdog_desc:
'Tämän ollessa käytössä, ja solmun varojen laskiessa alle LNbits-varojen määrän, otetaan automaattisesti käyttöön VoidWallet. Päivityksen jälkeen tämä asetus pitää tarkastaa uudelleen.',
watchdog_interval: 'Watchdog-aikaväli',
watchdog_interval_desc:
'Tällä määritetään kuinka usein taustatoiminto tarkistaa varojen Delta-muutokset [node_balance - lnbits_balance] killswitch-signaalille. Hakujen väli ilmoitetaan minuutteina.',
watchdog_delta: 'Watchdog Delta',
watchdog_delta_desc:
'Saldomuutoksen raja-arvo jolloin killswitch-muuttaa rahoituslähteeksi VoidWallet:in [lnbits_balance - node_balance > delta]',
status: 'Tilanne',
notification_source: 'Tiedotteiden lähde',
notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'enemmän',
less: 'vähemmän',
releases: 'Julkaisut',
killswitch: 'Killswitch',
watchdog: 'Watchdog',
server_logs: 'Palvelimen lokit',
ip_blocker: 'IP-suodatin',
security: 'Turvallisuus',
security_tools: 'Turvallisuus työkalut',
block_access_hint: 'Estä pääsy IP-osoitteen perusteella',
allow_access_hint: 'Salli pääsy IP-osoitteen perusteella (ohittaa estot)',
enter_ip: 'Anna IP ja paina +',
rate_limiter: 'Toiston rajoitin',
wallet_limiter: 'Lompakon Rajoitin',
wallet_limit_max_withdraw_per_day:
'Maksimi päivittäinen lompakon nosto sateissa (0 poistaa käytöstä)',
wallet_max_ballance: 'Lompakon maksimisaldo satosheina (0 poistaa käytöstä)',
wallet_limit_secs_between_trans:
'Min sekuntia transaktioiden välillä lompakkoa kohden (0 poistaa käytöstä)',
number_of_requests: 'Pyyntöjen lukumäärä',
time_unit: 'aikayksikkö',
minute: 'minuutti',
second: 'sekunti',
hour: 'tunti',
disable_server_log: 'Poista palvelimen loki käytöstä',
enable_server_log: 'Ota palvelimen loki käyttöön',
coming_soon: 'Ominaisuus on tulossa pian',
session_has_expired: 'Käyttämätön sessio on vanhentunut. Kirjaudu uudelleen.',
instant_access_question: 'Kirjaudu aikaisemmin luodulla tiedolla',
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
login_to_account: 'Kirjaudu käyttäjänimellä',
create_account: 'Luo tili',
account_settings: 'Tilin asetukset',
signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
password_repeat: 'Toista uusi salasana',
change_password: 'Vaihda salasana',
set_password: 'Aseta salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
login: 'Kirjaudu',
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
user_id: 'Käyttäjä ID',
email: 'Sähköposti',
first_name: 'Etunimi',
last_name: 'Sukunimi',
picture: 'Kuva',
verify_email: 'Vahvista sähköposti',
account: 'Tili',
update_account: 'Päivitä tiliä',
invalid_username: 'Virheellinen käyttäjänimi',
auth_provider: 'Tunnistamisen toimittaja',
my_account: 'Tilini',
back: 'Takaisin',
logout: 'Poistu',
look_and_feel: 'Kieli ja värit',
language: 'Kieli',
color_scheme: 'Väriteema'
}
+51 -4
View File
@@ -39,7 +39,7 @@ window.localisation.fr = {
paste_invoice_label: paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *', 'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description: lnbits_description:
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.", "Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, ZBD, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
export_to_phone: 'Exporter vers le téléphone avec un code QR', export_to_phone: 'Exporter vers le téléphone avec un code QR',
export_to_phone_desc: export_to_phone_desc:
"Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.", "Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.",
@@ -65,8 +65,10 @@ window.localisation.fr = {
service_fee_tooltip: service_fee_tooltip:
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante", "Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
toggle_darkmode: 'Basculer le mode sombre', toggle_darkmode: 'Basculer le mode sombre',
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits", payment_reactions: 'Réactions de paiement',
api_docs: "Documents de l'API", view_swagger_docs: "Voir les documentation de l'API Swagger de LNbits",
api_docs: "Documentation de l'API",
api_keys_api_docs: 'URL du nœud, clés API et documentation API',
lnbits_version: 'Version de LNbits', lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur', runs_on: 'Fonctionne sur',
credit_hint: 'Appuyez sur Entrée pour créditer le compte', credit_hint: 'Appuyez sur Entrée pour créditer le compte',
@@ -181,6 +183,7 @@ window.localisation.fr = {
notification_source_label: notification_source_label:
'URL source (utilisez uniquement la source officielle de statut LNbits et des sources de confiance)', 'URL source (utilisez uniquement la source officielle de statut LNbits et des sources de confiance)',
more: 'plus', more: 'plus',
less: 'moins',
releases: 'Versions', releases: 'Versions',
killswitch: "Interrupteur d'arrêt", killswitch: "Interrupteur d'arrêt",
watchdog: 'Chien de garde', watchdog: 'Chien de garde',
@@ -193,6 +196,13 @@ window.localisation.fr = {
"Autoriser l'accès par IP (cela passera outre les IP bloquées)", "Autoriser l'accès par IP (cela passera outre les IP bloquées)",
enter_ip: "Entrez l'adresse IP et appuyez sur Entrée", enter_ip: "Entrez l'adresse IP et appuyez sur Entrée",
rate_limiter: 'Limiteur de débit', rate_limiter: 'Limiteur de débit',
wallet_limiter: 'Limiteur de portefeuille',
wallet_limit_max_withdraw_per_day:
'Retrait quotidien maximum du portefeuille en sats (0 pour désactiver)',
wallet_max_ballance:
'Solde maximum du portefeuille en sats (0 pour désactiver)',
wallet_limit_secs_between_trans:
'Minutes et secondes entre les transactions par portefeuille (0 pour désactiver)',
number_of_requests: 'Nombre de requêtes', number_of_requests: 'Nombre de requêtes',
time_unit: 'Unité de temps', time_unit: 'Unité de temps',
minute: 'minute', minute: 'minute',
@@ -200,5 +210,42 @@ window.localisation.fr = {
hour: 'heure', hour: 'heure',
disable_server_log: 'Désactiver le journal du serveur', disable_server_log: 'Désactiver le journal du serveur',
enable_server_log: 'Activer le journal du serveur', enable_server_log: 'Activer le journal du serveur',
coming_soon: 'Fonctionnalité à venir bientôt' coming_soon: 'Fonctionnalité à venir bientôt',
session_has_expired: 'Votre session a expiré. Veuillez vous reconnecter.',
instant_access_question: 'Voulez-vous un accès instantané ?',
login_with_user_id: "Connexion avec l'identifiant utilisateur",
or: 'ou',
create_new_wallet: 'Créer un nouveau portefeuille',
login_to_account: 'Connectez-vous à votre compte',
create_account: 'Créer un compte',
account_settings: 'Paramètres du compte',
signin_with_google: 'Connectez-vous avec Google',
signin_with_github: 'Connectez-vous avec GitHub',
signin_with_keycloak: 'Connectez-vous avec Keycloak',
username_or_email: "Nom d'utilisateur ou e-mail",
password: 'Mot de passe',
password_config: 'Configuration du mot de passe',
password_repeat: 'Répétition du mot de passe',
change_password: 'Changer le mot de passe',
set_password: 'Définir le mot de passe',
invalid_password: 'Le mot de passe doit comporter au moins 8 caractères',
login: 'Connexion',
register: 'Inscrire',
username: "Nom d'utilisateur",
user_id: 'Identifiant utilisateur',
email: 'E-mail',
first_name: 'Prénom',
last_name: 'Nom de famille',
picture: 'Image',
verify_email: "Vérifiez l'e-mail avec",
account: 'Compte',
update_account: 'Mettre à jour le compte',
invalid_username: "Nom d'utilisateur invalide",
auth_provider: "Fournisseur d'authentification",
my_account: 'Mon compte',
back: 'Retour',
logout: 'Déconnexion',
look_and_feel: 'Apparence',
language: 'Langue',
color_scheme: 'Schéma de couleurs'
} }
+52 -4
View File
@@ -37,7 +37,7 @@ window.localisation.it = {
paste_invoice_label: paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *', 'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description: lnbits_description:
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie", "Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, ZBD, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
export_to_phone: 'Esportazione su telefono con codice QR', export_to_phone: 'Esportazione su telefono con codice QR',
export_to_phone_desc: export_to_phone_desc:
"Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.", "Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.",
@@ -62,8 +62,10 @@ window.localisation.it = {
service_fee_tooltip: service_fee_tooltip:
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita", "Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
toggle_darkmode: 'Attiva la modalità notturna', toggle_darkmode: 'Attiva la modalità notturna',
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits", payment_reactions: 'Reazioni al Pagamento',
api_docs: 'Documenti API', view_swagger_docs: "Visualizza i documentazione dell'API Swagger di LNbits",
api_docs: "Documentazione dell'API",
api_keys_api_docs: 'URL del nodo, chiavi API e documentazione API',
lnbits_version: 'Versione di LNbits', lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su', runs_on: 'Esegue su',
credit_hint: 'Premere Invio per accreditare i fondi', credit_hint: 'Premere Invio per accreditare i fondi',
@@ -177,6 +179,7 @@ window.localisation.it = {
notification_source_label: notification_source_label:
'URL sorgente (utilizzare solo la fonte ufficiale di stato LNbits e fonti di cui ti puoi fidare)', 'URL sorgente (utilizzare solo la fonte ufficiale di stato LNbits e fonti di cui ti puoi fidare)',
more: 'più', more: 'più',
less: 'meno',
releases: 'Pubblicazioni', releases: 'Pubblicazioni',
killswitch: 'Interruttore di spegnimento', killswitch: 'Interruttore di spegnimento',
watchdog: 'Cane da guardia', watchdog: 'Cane da guardia',
@@ -189,6 +192,13 @@ window.localisation.it = {
"Consenti l'accesso per IP (sovrascriverà gli IP bloccati)", "Consenti l'accesso per IP (sovrascriverà gli IP bloccati)",
enter_ip: "Inserisci l'IP e premi invio", enter_ip: "Inserisci l'IP e premi invio",
rate_limiter: 'Limitatore di frequenza', rate_limiter: 'Limitatore di frequenza',
wallet_limiter: 'Limitatore del Portafoglio',
wallet_limit_max_withdraw_per_day:
'Prelievo massimo giornaliero dal portafoglio in sats (0 per disabilitare)',
wallet_max_ballance:
'Saldo massimo del portafoglio in sats (0 per disabilitare)',
wallet_limit_secs_between_trans:
'Minuti e secondi tra transazioni per portafoglio (0 per disabilitare)',
number_of_requests: 'Numero di richieste', number_of_requests: 'Numero di richieste',
time_unit: 'Unità di tempo', time_unit: 'Unità di tempo',
minute: 'minuto', minute: 'minuto',
@@ -196,5 +206,43 @@ window.localisation.it = {
hour: 'ora', hour: 'ora',
disable_server_log: 'Disabilita Registro Server', disable_server_log: 'Disabilita Registro Server',
enable_server_log: 'Attiva Registro Server', enable_server_log: 'Attiva Registro Server',
coming_soon: 'Caratteristica in arrivo prossimamente' coming_soon: 'Caratteristica in arrivo prossimamente',
session_has_expired:
'La tua sessione è scaduta. Per favore, effettua nuovamente il login.',
instant_access_question: 'Vuoi accesso immediato?',
login_with_user_id: 'Accedi con ID utente',
or: 'oppure',
create_new_wallet: 'Crea nuovo portafoglio',
login_to_account: 'Accedi al tuo account',
create_account: 'Crea un account',
account_settings: "Impostazioni dell'account",
signin_with_google: 'Accedi con Google',
signin_with_github: 'Accedi con GitHub',
signin_with_keycloak: 'Accedi con Keycloak',
username_or_email: 'Nome utente o Email',
password: 'Password',
password_config: 'Configurazione della password',
password_repeat: 'Ripeti la password',
change_password: 'Cambia Password',
set_password: 'Imposta password',
invalid_password: 'La password deve contenere almeno 8 caratteri',
login: 'Accesso',
register: 'Registrati',
username: 'Nome utente',
user_id: 'ID utente',
email: 'Email',
first_name: 'Nome',
last_name: 'Cognome',
picture: 'Immagine',
verify_email: 'Verifica email con',
account: 'Conto',
update_account: 'Aggiorna Account',
invalid_username: 'Nome utente non valido',
auth_provider: 'Provider di Autenticazione',
my_account: 'Il mio account',
back: 'Indietro',
logout: 'Esci',
look_and_feel: 'Aspetto e Comportamento',
language: 'Lingua',
color_scheme: 'Schema dei colori'
} }
+49 -2
View File
@@ -35,7 +35,7 @@ window.localisation.jp = {
name_your_wallet: 'あなたのウォレットの名前 %{name}', name_your_wallet: 'あなたのウォレットの名前 %{name}',
paste_invoice_label: '請求書を貼り付けてください', paste_invoice_label: '請求書を貼り付けてください',
lnbits_description: lnbits_description:
'簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、Alby, LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。', '簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、Alby, ZBD, LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
export_to_phone: '電話にエクスポート', export_to_phone: '電話にエクスポート',
export_to_phone_desc: export_to_phone_desc:
'ウォレットを電話にエクスポートすると、ウォレットを削除する前にウォレットを復元できます。ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。', 'ウォレットを電話にエクスポートすると、ウォレットを削除する前にウォレットを復元できます。ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。',
@@ -59,8 +59,10 @@ window.localisation.jp = {
service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)', service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)',
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料', service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
toggle_darkmode: 'ダークモードを切り替える', toggle_darkmode: 'ダークモードを切り替える',
payment_reactions: '支払いの反応',
view_swagger_docs: 'Swaggerドキュメントを表示', view_swagger_docs: 'Swaggerドキュメントを表示',
api_docs: 'APIドキュメント', api_docs: 'APIドキュメント',
api_keys_api_docs: 'ノードURL、APIキー、APIドキュメント',
lnbits_version: 'LNbits バージョン', lnbits_version: 'LNbits バージョン',
runs_on: 'で実行', runs_on: 'で実行',
credit_hint: credit_hint:
@@ -174,6 +176,7 @@ window.localisation.jp = {
notification_source_label: notification_source_label:
'ソースURL(公式のLNbitsステータスソースのみを使用し、信頼できるソースのみを利用してください)', 'ソースURL(公式のLNbitsステータスソースのみを使用し、信頼できるソースのみを利用してください)',
more: 'より多くの', more: 'より多くの',
less: '少ない',
releases: 'リリース', releases: 'リリース',
killswitch: 'キルスイッチ', killswitch: 'キルスイッチ',
watchdog: 'ウォッチドッグ', watchdog: 'ウォッチドッグ',
@@ -186,6 +189,12 @@ window.localisation.jp = {
'IPによるアクセスを許可する(ブロックされたIPを上書きします)', 'IPによるアクセスを許可する(ブロックされたIPを上書きします)',
enter_ip: 'IPを入力してエンターキーを押してください', enter_ip: 'IPを入力してエンターキーを押してください',
rate_limiter: 'レートリミッター', rate_limiter: 'レートリミッター',
wallet_limiter: 'ウォレットリミッター',
wallet_limit_max_withdraw_per_day:
'1日あたりの最大ウォレット出金額をsatsで入力してください(0 で無効)。',
wallet_max_ballance: 'ウォレットの最大残高(sats)(0は無効)',
wallet_limit_secs_between_trans:
'トランザクション間の最小秒数(ウォレットごと)(0は無効)',
number_of_requests: 'リクエストの数', number_of_requests: 'リクエストの数',
time_unit: '時間単位', time_unit: '時間単位',
minute: '分', minute: '分',
@@ -193,5 +202,43 @@ window.localisation.jp = {
hour: '時間', hour: '時間',
disable_server_log: 'サーバーログを無効にする', disable_server_log: 'サーバーログを無効にする',
enable_server_log: 'サーバーログを有効にする', enable_server_log: 'サーバーログを有効にする',
coming_soon: '機能は間もなく登場します' coming_soon: '機能は間もなく登場します',
session_has_expired:
'あなたのセッションは期限切れです。もう一度ログインしてください。',
instant_access_question: '即時アクセスをご希望ですか?',
login_with_user_id: 'ユーザーIDでログイン',
or: 'または',
create_new_wallet: '新しいウォレットを作成',
login_to_account: 'アカウントにログインしてください',
create_account: 'アカウントを作成',
account_settings: 'アカウント設定',
signin_with_google: 'Googleでサインイン',
signin_with_github: 'GitHubでサインイン',
signin_with_keycloak: 'Keycloakでサインイン',
username_or_email: 'ユーザー名またはメールアドレス',
password: 'パスワード',
password_config: 'パスワード設定',
password_repeat: 'パスワードの再入力',
change_password: 'パスワードを変更',
set_password: 'パスワードを設定',
invalid_password: 'パスワードは少なくとも8文字必要です',
login: 'ログイン',
register: '登録',
username: 'ユーザー名',
user_id: 'ユーザーID',
email: 'メール',
first_name: '名',
last_name: '姓',
picture: '写真',
verify_email: 'メールアドレスの確認を行ってください',
account: 'アカウント',
update_account: 'アカウントを更新',
invalid_username: '無効なユーザー名',
auth_provider: '認証プロバイダ',
my_account: 'マイアカウント',
back: '戻る',
logout: 'ログアウト',
look_and_feel: 'ルック・アンド・フィール',
language: '言語',
color_scheme: 'カラースキーム'
} }
+47 -2
View File
@@ -36,7 +36,7 @@ window.localisation.kr = {
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요', name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *', paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description: lnbits_description:
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다. 현재 지원하는 예산 자원의 형태는 LND, Core Lightning, OpenNode, Alby, LNPay, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.', '설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다. 현재 지원하는 예산 자원의 형태는 LND, Core Lightning, OpenNode, Alby, ZBD, LNPay, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기', export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
export_to_phone_desc: export_to_phone_desc:
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.', '이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
@@ -60,8 +60,10 @@ window.localisation.kr = {
service_fee_tooltip: service_fee_tooltip:
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료', '지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환', toggle_darkmode: '다크 모드 전환',
payment_reactions: '결제 반응',
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다', view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
api_docs: 'API 문서', api_docs: 'API 문서',
api_keys_api_docs: '노드 URL, API 키와 API 문서',
lnbits_version: 'LNbits 버전', lnbits_version: 'LNbits 버전',
runs_on: 'Runs on', runs_on: 'Runs on',
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요', credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
@@ -174,6 +176,7 @@ window.localisation.kr = {
notification_source_label: notification_source_label:
'알림 메세지를 가져올 URL (공식 LNbits 상황판 출처나, 당신이 신뢰할 수 있는 출처만을 사용하세요)', '알림 메세지를 가져올 URL (공식 LNbits 상황판 출처나, 당신이 신뢰할 수 있는 출처만을 사용하세요)',
more: '더 알아보기', more: '더 알아보기',
less: '적게',
releases: '배포 버전들', releases: '배포 버전들',
killswitch: '비상 정지', killswitch: '비상 정지',
watchdog: '와치독', watchdog: '와치독',
@@ -185,6 +188,11 @@ window.localisation.kr = {
allow_access_hint: 'IP 기준으로 접속 허용하기 (차단한 IP들을 무시합니다)', allow_access_hint: 'IP 기준으로 접속 허용하기 (차단한 IP들을 무시합니다)',
enter_ip: 'IP 주소를 입력하고 Enter를 눌러주세요', enter_ip: 'IP 주소를 입력하고 Enter를 눌러주세요',
rate_limiter: '횟수로 제한하기', rate_limiter: '횟수로 제한하기',
wallet_limiter: '지갑 제한기',
wallet_limit_max_withdraw_per_day:
'일일 최대 지갑 출금액(sats) (0은 비활성화)',
wallet_max_ballance: '지갑 최대 잔액(sats) (0은 비활성화)',
wallet_limit_secs_between_trans: '지갑 당 거래 사이 최소 초 (0은 비활성화)',
number_of_requests: '요청 횟수', number_of_requests: '요청 횟수',
time_unit: '시간 단위', time_unit: '시간 단위',
minute: '분', minute: '분',
@@ -192,5 +200,42 @@ window.localisation.kr = {
hour: '시간', hour: '시간',
disable_server_log: '서버 로깅 중단하기', disable_server_log: '서버 로깅 중단하기',
enable_server_log: '서버 로깅 활성화하기', enable_server_log: '서버 로깅 활성화하기',
coming_soon: '곧 구현될 기능들입니다' coming_soon: '곧 구현될 기능들입니다',
session_has_expired: '세션 유효 기간이 만료되었습니다. 다시 로그인해 주세요.',
instant_access_question: '즉시 액세스하시겠습니까?',
login_with_user_id: '사용자 ID로 로그인',
or: '또는',
create_new_wallet: '새 지갑 만들기',
login_to_account: '계정에 로그인하세요.',
create_account: '계정 생성',
account_settings: '계정 설정',
signin_with_google: 'Google으로 로그인',
signin_with_github: 'GitHub으로 로그인',
signin_with_keycloak: 'Keycloak으로 로그인',
username_or_email: '사용자 이름 또는 이메일',
password: '비밀번호',
password_config: '비밀번호 설정',
password_repeat: '비밀번호 재입력',
change_password: '비밀번호 변경',
set_password: '비밀번호 설정',
invalid_password: '비밀번호는 최소 8자 이상이어야 합니다',
login: '로그인',
register: '등록',
username: '사용자 이름',
user_id: '사용자 ID',
email: '이메일',
first_name: '성명',
last_name: '성',
picture: '사진',
verify_email: '이메일을 인증하려면',
account: '계정',
update_account: '계정 업데이트',
invalid_username: '잘못된 사용자 이름',
auth_provider: '인증 제공자',
my_account: '내 계정',
back: '뒤로',
logout: '로그아웃',
look_and_feel: '외관과 느낌',
language: '언어',
color_scheme: '색상 구성'
} }
+49 -2
View File
@@ -37,7 +37,7 @@ window.localisation.nl = {
name_your_wallet: 'Geef je %{name} portemonnee een naam', name_your_wallet: 'Geef je %{name} portemonnee een naam',
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*', paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
lnbits_description: lnbits_description:
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien, ondersteunt op dit moment LND, Core Lightning, OpenNode, Alby, LNPay en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.', 'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien, ondersteunt op dit moment LND, Core Lightning, OpenNode, Alby, ZBD, LNPay en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
export_to_phone: 'Exporteren naar telefoon met QR-code', export_to_phone: 'Exporteren naar telefoon met QR-code',
export_to_phone_desc: export_to_phone_desc:
'Deze QR-code bevat uw portemonnee-URL met volledige toegang. U kunt het vanaf uw telefoon scannen om uw portemonnee van daaruit te openen.', 'Deze QR-code bevat uw portemonnee-URL met volledige toegang. U kunt het vanaf uw telefoon scannen om uw portemonnee van daaruit te openen.',
@@ -63,8 +63,10 @@ window.localisation.nl = {
service_fee_tooltip: service_fee_tooltip:
'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie', 'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie',
toggle_darkmode: 'Donkere modus aan/uit', toggle_darkmode: 'Donkere modus aan/uit',
payment_reactions: 'Betalingsreacties',
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie', view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
api_docs: 'API-documentatie', api_docs: 'API-documentatie',
api_keys_api_docs: 'Node URL, API-sleutels en API-documentatie',
lnbits_version: 'LNbits-versie', lnbits_version: 'LNbits-versie',
runs_on: 'Draait op', runs_on: 'Draait op',
credit_hint: 'Druk op Enter om de rekening te crediteren', credit_hint: 'Druk op Enter om de rekening te crediteren',
@@ -177,6 +179,7 @@ window.localisation.nl = {
notification_source_label: notification_source_label:
'Bron-URL (gebruik alleen de officiële LNbits-statusbron en bronnen die u vertrouwt)', 'Bron-URL (gebruik alleen de officiële LNbits-statusbron en bronnen die u vertrouwt)',
more: 'meer', more: 'meer',
less: 'minder',
releases: 'Uitgaven', releases: 'Uitgaven',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Waakhond', watchdog: 'Waakhond',
@@ -189,6 +192,13 @@ window.localisation.nl = {
"Toegang verlenen op basis van IP (zal geblokkeerde IP's overschrijven)", "Toegang verlenen op basis van IP (zal geblokkeerde IP's overschrijven)",
enter_ip: 'Voer IP in en druk op enter', enter_ip: 'Voer IP in en druk op enter',
rate_limiter: 'Snelheidsbegrenzer', rate_limiter: 'Snelheidsbegrenzer',
wallet_limiter: 'Portemonnee Limietsteller',
wallet_limit_max_withdraw_per_day:
'Maximale dagelijkse opname van wallet in sats (0 om uit te schakelen)',
wallet_max_ballance:
'Maximale portefeuillesaldo in sats (0 om uit te schakelen)',
wallet_limit_secs_between_trans:
'Min seconden tussen transacties per portemonnee (0 om uit te schakelen)',
number_of_requests: 'Aantal verzoeken', number_of_requests: 'Aantal verzoeken',
time_unit: 'Tijdeenheid', time_unit: 'Tijdeenheid',
minute: 'minuut', minute: 'minuut',
@@ -196,5 +206,42 @@ window.localisation.nl = {
hour: 'uur', hour: 'uur',
disable_server_log: 'Serverlog uitschakelen', disable_server_log: 'Serverlog uitschakelen',
enable_server_log: 'Activeer Serverlog', enable_server_log: 'Activeer Serverlog',
coming_soon: 'Functie binnenkort beschikbaar' coming_soon: 'Functie binnenkort beschikbaar',
session_has_expired: 'Uw sessie is verlopen. Log alstublieft opnieuw in.',
instant_access_question: 'Wil je directe toegang?',
login_with_user_id: 'Inloggen met gebruikers-ID',
or: 'of',
create_new_wallet: 'Nieuwe portemonnee aanmaken',
login_to_account: 'Log in op je account',
create_account: 'Account aanmaken',
account_settings: 'Accountinstellingen',
signin_with_google: 'Inloggen met Google',
signin_with_github: 'Inloggen met GitHub',
signin_with_keycloak: 'Inloggen met Keycloak',
username_or_email: 'Gebruikersnaam of e-mail',
password: 'Wachtwoord',
password_config: 'Wachtwoordconfiguratie',
password_repeat: 'Wachtwoord herhalen',
change_password: 'Wachtwoord wijzigen',
set_password: 'Wachtwoord instellen',
invalid_password: 'Wachtwoord moet ten minste 8 tekens bevatten',
login: 'Inloggen',
register: 'Registreren',
username: 'Gebruikersnaam',
user_id: 'Gebruikers-ID',
email: 'E-mail',
first_name: 'Voornaam',
last_name: 'Achternaam',
picture: 'Foto',
verify_email: 'E-mail verifiëren met',
account: 'Account',
update_account: 'Account bijwerken',
invalid_username: 'Ongeldige gebruikersnaam',
auth_provider: 'Auth Provider',
my_account: 'Mijn Account',
back: 'Terug',
logout: 'Afmelden',
look_and_feel: 'Uiterlijk en gedrag',
language: 'Taal',
color_scheme: 'Kleurenschema'
} }
+48 -2
View File
@@ -36,7 +36,7 @@ window.localisation.pi = {
name_your_wallet: 'Name yer %{name} treasure chest', name_your_wallet: 'Name yer %{name} treasure chest',
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!', paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
lnbits_description: lnbits_description:
'Arr, easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.', 'Arr, easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, ZBD, LNPay and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
export_to_phone: 'Export to Phone with QR Code, me hearties', export_to_phone: 'Export to Phone with QR Code, me hearties',
export_to_phone_desc: export_to_phone_desc:
'This QR code contains yer chest URL with full access. Ye can scan it from yer phone to open yer chest from there, arr!', 'This QR code contains yer chest URL with full access. Ye can scan it from yer phone to open yer chest from there, arr!',
@@ -61,8 +61,10 @@ window.localisation.pi = {
service_fee_tooltip: service_fee_tooltip:
"Service fee charged by the LNbits server admin per goin' transaction", "Service fee charged by the LNbits server admin per goin' transaction",
toggle_darkmode: 'Toggle Dark Mode, arr!', toggle_darkmode: 'Toggle Dark Mode, arr!',
payment_reactions: 'Payment Reactions',
view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets', view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets',
api_docs: 'API docs for the scallywags', api_docs: 'API docs for the scallywags',
api_keys_api_docs: 'Node URL, API keys and API docs',
lnbits_version: 'LNbits version, arr!', lnbits_version: 'LNbits version, arr!',
runs_on: 'Runs on, matey', runs_on: 'Runs on, matey',
credit_hint: 'Press Enter to credit account and make it richer', credit_hint: 'Press Enter to credit account and make it richer',
@@ -176,6 +178,7 @@ window.localisation.pi = {
notification_source_label: notification_source_label:
"Source URL (only use th' official LNbits status source, and sources ye can trust)", "Source URL (only use th' official LNbits status source, and sources ye can trust)",
more: "Arr, 'tis more.", more: "Arr, 'tis more.",
less: "Arr, 'tis more fewer.",
releases: 'Releases', releases: 'Releases',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Seadog', watchdog: 'Seadog',
@@ -187,6 +190,12 @@ window.localisation.pi = {
allow_access_hint: 'Grant permission by IP (will override barred IPs)', allow_access_hint: 'Grant permission by IP (will override barred IPs)',
enter_ip: 'Enter IP and hit enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Pouch Limitar',
wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 ter disable)',
wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)',
wallet_limit_secs_between_trans:
"Min secs 'tween transactions per wallet (0 to disable)",
number_of_requests: "Number o' requests", number_of_requests: "Number o' requests",
time_unit: "time bein'", time_unit: "time bein'",
minute: 'minnit', minute: 'minnit',
@@ -194,5 +203,42 @@ window.localisation.pi = {
hour: 'hour', hour: 'hour',
disable_server_log: "Disabl' %{Server} Log", disable_server_log: "Disabl' %{Server} Log",
enable_server_log: 'Enable Server Log', enable_server_log: 'Enable Server Log',
coming_soon: "Feature comin' soon" coming_soon: "Feature comin' soon",
session_has_expired: 'Yer session has expired. Please login again.',
instant_access_question: "Be wantin' quick entry, aye?",
login_with_user_id: 'Login with user ID',
or: 'arr',
create_new_wallet: 'Create New Wallet',
login_to_account: 'Log in to yer account',
create_account: 'Create account',
account_settings: "Account Settin's",
signin_with_google: "Sign in wit' Google",
signin_with_github: "Sign in wit' GitHub",
signin_with_keycloak: "Sign in wit' Keycloak",
username_or_email: 'Usarrrname or Email',
password: 'Passwarrd',
password_config: 'Passwarrd Config',
password_repeat: 'Passwarrd repeat',
change_password: 'Change Passwarrd',
set_password: 'Set yer Secret Code',
invalid_password: "Passwarrd must be havin' at leest 8 charrracters",
login: 'Log in',
register: 'Sign on',
username: 'Username',
user_id: 'User ID',
email: 'Email',
first_name: 'Firrrst Name',
last_name: 'Surname',
picture: "pictur'",
verify_email: 'Verify email with',
account: 'Arrrccount',
update_account: "Updatin' Arrrccount",
invalid_username: 'Username be not valid, matey!',
auth_provider: 'Auth Provider becometh Auth Provider, ye see?',
my_account: 'Me Arrrccount',
back: 'Return',
logout: 'Log out yer session',
look_and_feel: 'Look and Feel',
language: 'Langwidge',
color_scheme: 'Colour Scheme'
} }
+48 -2
View File
@@ -35,7 +35,7 @@ window.localisation.pl = {
name_your_wallet: 'Nazwij swój portfel %{name}', name_your_wallet: 'Nazwij swój portfel %{name}',
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *', paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
lnbits_description: lnbits_description:
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning, obecnie wspiera LND, Core Lightning, OpenNode, Alby, LNPay czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR', 'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning, obecnie wspiera LND, Core Lightning, OpenNode, Alby, ZBD, LNPay czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
export_to_phone: 'Eksport kodu QR na telefon', export_to_phone: 'Eksport kodu QR na telefon',
export_to_phone_desc: export_to_phone_desc:
'Ten kod QR zawiera adres URL Twojego portfela z pełnym dostępem do niego. Możesz go zeskanować na swoim telefonie aby otworzyć na nim ten portfel.', 'Ten kod QR zawiera adres URL Twojego portfela z pełnym dostępem do niego. Możesz go zeskanować na swoim telefonie aby otworzyć na nim ten portfel.',
@@ -60,8 +60,10 @@ window.localisation.pl = {
service_fee_tooltip: service_fee_tooltip:
'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję', 'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję',
toggle_darkmode: 'Tryb nocny', toggle_darkmode: 'Tryb nocny',
payment_reactions: 'Reakcje na płatność',
view_swagger_docs: 'Dokumentacja Swagger API', view_swagger_docs: 'Dokumentacja Swagger API',
api_docs: 'Dokumentacja API', api_docs: 'Dokumentacja API',
api_keys_api_docs: 'Adres URL węzła, klucze API i dokumentacja API',
lnbits_version: 'Wersja LNbits', lnbits_version: 'Wersja LNbits',
runs_on: 'Działa na', runs_on: 'Działa na',
credit_hint: 'Naciśnij Enter aby doładować konto', credit_hint: 'Naciśnij Enter aby doładować konto',
@@ -174,6 +176,7 @@ window.localisation.pl = {
notification_source_label: notification_source_label:
'Adres URL źródła (używaj tylko oficjalnego źródła statusu LNbits oraz źródeł, którym możesz zaufać)', 'Adres URL źródła (używaj tylko oficjalnego źródła statusu LNbits oraz źródeł, którym możesz zaufać)',
more: 'więcej', more: 'więcej',
less: 'mniej',
releases: 'Wydania', releases: 'Wydania',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Pies gończy', watchdog: 'Pies gończy',
@@ -186,6 +189,12 @@ window.localisation.pl = {
'Zezwól na dostęp przez IP (zignoruje zablokowane adresy IP)', 'Zezwól na dostęp przez IP (zignoruje zablokowane adresy IP)',
enter_ip: 'Wpisz adres IP i naciśnij enter', enter_ip: 'Wpisz adres IP i naciśnij enter',
rate_limiter: 'Ogranicznik Częstotliwości', rate_limiter: 'Ogranicznik Częstotliwości',
wallet_limiter: 'Ogranicznik Portfela',
wallet_limit_max_withdraw_per_day:
'Maksymalna dzienna wypłata z portfela w satoshi (0 aby wyłączyć)',
wallet_max_ballance: 'Maksymalny stan portfela w satoshi (0 aby wyłączyć)',
wallet_limit_secs_between_trans:
'Min sekund pomiędzy transakcjami na portfel (0 aby wyłączyć)',
number_of_requests: 'Liczba żądań', number_of_requests: 'Liczba żądań',
time_unit: 'Jednostka czasu', time_unit: 'Jednostka czasu',
minute: 'minuta', minute: 'minuta',
@@ -193,5 +202,42 @@ window.localisation.pl = {
hour: 'godzina', hour: 'godzina',
disable_server_log: 'Wyłącz log serwera', disable_server_log: 'Wyłącz log serwera',
enable_server_log: 'Włącz dziennik serwera', enable_server_log: 'Włącz dziennik serwera',
coming_soon: 'Funkcja wkrótce będzie dostępna' coming_soon: 'Funkcja wkrótce będzie dostępna',
session_has_expired: 'Twoja sesja wygasła. Proszę zaloguj się ponownie.',
instant_access_question: 'Chcesz mieć natychmiastowy dostęp?',
login_with_user_id: 'Zaloguj się za pomocą identyfikatora użytkownika',
or: 'lub',
create_new_wallet: 'Utwórz nowy portfel',
login_to_account: 'Zaloguj się do swojego konta',
create_account: 'Załóż konto',
account_settings: 'Ustawienia konta',
signin_with_google: 'Zaloguj się przez Google',
signin_with_github: 'Zaloguj się przez GitHub',
signin_with_keycloak: 'Zaloguj się przez Keycloak',
username_or_email: 'Nazwa użytkownika lub Email',
password: 'Hasło',
password_config: 'Konfiguracja Hasła',
password_repeat: 'Powtórz hasło',
change_password: 'Zmień hasło',
set_password: 'Ustaw hasło',
invalid_password: 'Hasło musi zawierać co najmniej 8 znaków',
login: 'Logowanie',
register: 'Zarejestruj',
username: 'Nazwa użytkownika',
user_id: 'Identyfikator użytkownika',
email: 'Email',
first_name: 'Imię',
last_name: 'Nazwisko',
picture: 'Zdjęcie',
verify_email: 'Zweryfikuj email za pomocą',
account: 'Konto',
update_account: 'Aktualizuj konto',
invalid_username: 'Nieprawidłowa nazwa użytkownika',
auth_provider: 'Dostawca uwierzytelniania',
my_account: 'Moje Konto',
back: 'Wstecz',
logout: 'Wyloguj',
look_and_feel: 'Wygląd i zachowanie',
language: 'Język',
color_scheme: 'Schemat kolorów'
} }
+48 -2
View File
@@ -36,7 +36,7 @@ window.localisation.pt = {
name_your_wallet: 'Nomeie sua carteira %{name}', name_your_wallet: 'Nomeie sua carteira %{name}',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *', paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description: lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.', 'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, ZBD, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
export_to_phone: 'Exportar para o telefone com código QR', export_to_phone: 'Exportar para o telefone com código QR',
export_to_phone_desc: export_to_phone_desc:
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.', 'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
@@ -61,8 +61,10 @@ window.localisation.pt = {
service_fee_tooltip: service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída', 'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro', toggle_darkmode: 'Alternar modo escuro',
payment_reactions: 'Reações de Pagamento',
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger', view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
api_docs: 'Documentação da API', api_docs: 'Documentação da API',
api_keys_api_docs: 'URL do Nó, chaves de API e documentação de API',
lnbits_version: 'Versão do LNbits', lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em', runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta', credit_hint: 'Pressione Enter para creditar a conta',
@@ -176,6 +178,7 @@ window.localisation.pt = {
notification_source_label: notification_source_label:
'URL de Origem (use apenas a fonte oficial de status do LNbits e fontes em que confia)', 'URL de Origem (use apenas a fonte oficial de status do LNbits e fontes em que confia)',
more: 'mais', more: 'mais',
less: 'menos',
releases: 'Lançamentos', releases: 'Lançamentos',
killswitch: 'Interruptor de desativação', killswitch: 'Interruptor de desativação',
watchdog: 'Cão de guarda', watchdog: 'Cão de guarda',
@@ -187,6 +190,12 @@ window.localisation.pt = {
allow_access_hint: 'Permitir acesso por IP (substituirá IPs bloqueados)', allow_access_hint: 'Permitir acesso por IP (substituirá IPs bloqueados)',
enter_ip: 'Digite o IP e pressione enter.', enter_ip: 'Digite o IP e pressione enter.',
rate_limiter: 'Limitador de Taxa', rate_limiter: 'Limitador de Taxa',
wallet_limiter: 'Limitador de Carteira',
wallet_limit_max_withdraw_per_day:
'Limite diário máximo de saque da carteira em sats (0 para desativar)',
wallet_max_ballance: 'Saldo máximo da carteira em sats (0 para desativar)',
wallet_limit_secs_between_trans:
'Minutos seg. entre transações por carteira (0 para desativar)',
number_of_requests: 'Número de solicitações', number_of_requests: 'Número de solicitações',
time_unit: 'Unidade de tempo', time_unit: 'Unidade de tempo',
minute: 'minuto', minute: 'minuto',
@@ -194,5 +203,42 @@ window.localisation.pt = {
hour: 'hora', hour: 'hora',
disable_server_log: 'Desativar Log do Servidor', disable_server_log: 'Desativar Log do Servidor',
enable_server_log: 'Ativar Log do Servidor', enable_server_log: 'Ativar Log do Servidor',
coming_soon: 'Funcionalidade em breve' coming_soon: 'Funcionalidade em breve',
session_has_expired: 'Sua sessão expirou. Por favor, faça login novamente.',
instant_access_question: 'Quer acesso imediato?',
login_with_user_id: 'Entrar com ID do usuário',
or: 'ou',
create_new_wallet: 'Criar Nova Carteira',
login_to_account: 'Faça login na sua conta',
create_account: 'Criar conta',
account_settings: 'Configurações da Conta',
signin_with_google: 'Entrar com o Google',
signin_with_github: 'Entrar com o GitHub',
signin_with_keycloak: 'Entrar com o Keycloak',
username_or_email: 'Nome de usuário ou Email',
password: 'Senha',
password_config: 'Configuração de Senha',
password_repeat: 'Repetição de senha',
change_password: 'Alterar Senha',
set_password: 'Definir Senha',
invalid_password: 'A senha deve ter pelo menos 8 caracteres',
login: 'Entrar',
register: 'Registrar',
username: 'Nome de usuário',
user_id: 'ID do Usuário',
email: 'E-mail',
first_name: 'Nome próprio',
last_name: 'Sobrenome',
picture: 'Foto',
verify_email: 'Verifique o e-mail com',
account: 'Conta',
update_account: 'Atualizar Conta',
invalid_username: 'Nome de usuário inválido',
auth_provider: 'Provedor de Autenticação',
my_account: 'Minha Conta',
back: 'Voltar',
logout: 'Sair',
look_and_feel: 'Aparência e Sensação',
language: 'Idioma',
color_scheme: 'Esquema de Cores'
} }
+49 -2
View File
@@ -35,7 +35,7 @@ window.localisation.sk = {
name_your_wallet: 'Pomenujte vašu %{name} peňaženku', name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *', paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
lnbits_description: lnbits_description:
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania lightning-network, momentálne podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.', 'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania lightning-network, momentálne podporuje LND, Core Lightning, OpenNode, Alby, ZBD, LNPay a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
export_to_phone: 'Exportovať do telefónu s QR kódom', export_to_phone: 'Exportovať do telefónu s QR kódom',
export_to_phone_desc: export_to_phone_desc:
'Tento QR kód obsahuje URL vašej peňaženky s plným prístupom. Môžete ho naskenovať z vášho telefónu a otvoriť vašu peňaženku odtiaľ.', 'Tento QR kód obsahuje URL vašej peňaženky s plným prístupom. Môžete ho naskenovať z vášho telefónu a otvoriť vašu peňaženku odtiaľ.',
@@ -60,8 +60,10 @@ window.localisation.sk = {
service_fee_tooltip: service_fee_tooltip:
'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu', 'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu',
toggle_darkmode: 'Prepnúť Tmavý režim', toggle_darkmode: 'Prepnúť Tmavý režim',
payment_reactions: 'Reakcie na platbu',
view_swagger_docs: 'Zobraziť LNbits Swagger API dokumentáciu', view_swagger_docs: 'Zobraziť LNbits Swagger API dokumentáciu',
api_docs: 'API dokumentácia', api_docs: 'API dokumentácia',
api_keys_api_docs: 'Adresa uzla, API kľúče a API dokumentácia',
lnbits_version: 'Verzia LNbits', lnbits_version: 'Verzia LNbits',
runs_on: 'Beží na', runs_on: 'Beží na',
credit_hint: 'Stlačte Enter pre pripísanie na účet', credit_hint: 'Stlačte Enter pre pripísanie na účet',
@@ -174,6 +176,7 @@ window.localisation.sk = {
notification_source_label: notification_source_label:
'URL zdroja (používajte len oficiálny LNbits zdroj stavu a zdroje, ktorým môžete dôverovať)', 'URL zdroja (používajte len oficiálny LNbits zdroj stavu a zdroje, ktorým môžete dôverovať)',
more: 'viac', more: 'viac',
less: 'menej',
releases: 'Vydania', releases: 'Vydania',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Watchdog', watchdog: 'Watchdog',
@@ -185,6 +188,13 @@ window.localisation.sk = {
allow_access_hint: 'Povoliť prístup podľa IP (prebije blokované IP)', allow_access_hint: 'Povoliť prístup podľa IP (prebije blokované IP)',
enter_ip: 'Zadajte IP a stlačte enter', enter_ip: 'Zadajte IP a stlačte enter',
rate_limiter: 'Obmedzovač počtu požiadaviek', rate_limiter: 'Obmedzovač počtu požiadaviek',
wallet_limiter: 'Obmedzovač peňaženky',
wallet_limit_max_withdraw_per_day:
'Maximálny denný výber z peňaženky v satošiach (0 pre zrušenie)',
wallet_max_ballance:
'Maximálny zostatok v peňaženke v satošiach (0 pre deaktiváciu)',
wallet_limit_secs_between_trans:
'Minimálny počet sekúnd medzi transakciami na peňaženku (0 na deaktiváciu)',
number_of_requests: 'Počet požiadaviek', number_of_requests: 'Počet požiadaviek',
time_unit: 'Časová jednotka', time_unit: 'Časová jednotka',
minute: 'minúta', minute: 'minúta',
@@ -192,5 +202,42 @@ window.localisation.sk = {
hour: 'hodina', hour: 'hodina',
disable_server_log: 'Zakázať Log servera', disable_server_log: 'Zakázať Log servera',
enable_server_log: 'Povoliť Log servera', enable_server_log: 'Povoliť Log servera',
coming_soon: 'Funkcia bude čoskoro dostupná' coming_soon: 'Funkcia bude čoskoro dostupná',
session_has_expired: 'Vaša relácia vypršala. Prosím, prihláste sa znova.',
instant_access_question: 'Chcete okamžitý prístup?',
login_with_user_id: 'Prihlásiť sa s používateľským ID',
or: 'alebo',
create_new_wallet: 'Vytvoriť novú peňaženku',
login_to_account: 'Prihláste sa do vášho účtu',
create_account: 'Vytvoriť účet',
account_settings: 'Nastavenia účtu',
signin_with_google: 'Prihlásiť sa pomocou Google',
signin_with_github: 'Prihlásiť sa pomocou GitHub',
signin_with_keycloak: 'Prihlásiť sa pomocou Keycloak',
username_or_email: 'Používateľské meno alebo email',
password: 'Heslo',
password_config: 'Konfigurácia hesla',
password_repeat: 'Opakovanie hesla',
change_password: 'Zmeniť heslo',
set_password: 'Nastaviť heslo',
invalid_password: 'Heslo musí mať aspoň 8 znakov',
login: 'Prihlásenie',
register: 'Registrovať',
username: 'Používateľské meno',
user_id: 'ID používateľa',
email: 'Email',
first_name: 'Meno',
last_name: 'Priezvisko',
picture: 'Obrázok',
verify_email: 'Overiť e-mail s',
account: 'Účet',
update_account: 'Aktualizovať účet',
invalid_username: 'Neplatné užívateľské meno',
auth_provider: 'Poskytovateľ autentifikácie',
my_account: 'Môj účet',
back: 'Späť',
logout: 'Odhlásiť sa',
look_and_feel: 'Vzhľad a dojem',
language: 'Jazyk',
color_scheme: 'Farebná schéma'
} }
+49 -3
View File
@@ -35,7 +35,7 @@ window.localisation.we = {
name_your_wallet: 'Enwch eich waled %{name}', name_your_wallet: 'Enwch eich waled %{name}',
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *', paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
lnbits_description: lnbits_description:
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt, ar hyn o bryd yn cefnogi LND, Core Lightning, OpenNode, Alby, LNPay a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.', 'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt, ar hyn o bryd yn cefnogi LND, Core Lightning, OpenNode, Alby, ZBD, LNPay a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
export_to_phone: 'Allforio i Ffôn gyda chod QR', export_to_phone: 'Allforio i Ffôn gyda chod QR',
export_to_phone_desc: export_to_phone_desc:
'Mae`r cod QR hwn yn cynnwys URL eich waled gyda mynediad llawn. Gallwch ei sganio o`ch ffôn i agor eich waled oddi yno.', 'Mae`r cod QR hwn yn cynnwys URL eich waled gyda mynediad llawn. Gallwch ei sganio o`ch ffôn i agor eich waled oddi yno.',
@@ -61,8 +61,10 @@ window.localisation.we = {
service_fee_tooltip: service_fee_tooltip:
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan", "Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
toggle_darkmode: 'Toglo Modd Tywyll', toggle_darkmode: 'Toglo Modd Tywyll',
payment_reactions: 'Adweithiau Talu',
view_swagger_docs: 'Gweld dogfennau API LNbits Swagger', view_swagger_docs: 'Gweld dogfennau API LNbits Swagger',
api_docs: 'API docs', api_docs: 'Dogfennau API',
api_keys_api_docs: 'URL y nod, allweddi API a dogfennau API',
lnbits_version: 'Fersiwn LNbits', lnbits_version: 'Fersiwn LNbits',
runs_on: 'Yn rhedeg ymlaen', runs_on: 'Yn rhedeg ymlaen',
credit_hint: 'Pwyswch Enter i gyfrif credyd', credit_hint: 'Pwyswch Enter i gyfrif credyd',
@@ -174,6 +176,7 @@ window.localisation.we = {
notification_source_label: notification_source_label:
'URL Ffynhonnell (defnyddiwch yn unig ffynhonnell statws swyddogol LNbits, a ffynonellau y gallwch ymddiried ynddynt)', 'URL Ffynhonnell (defnyddiwch yn unig ffynhonnell statws swyddogol LNbits, a ffynonellau y gallwch ymddiried ynddynt)',
more: 'mwy', more: 'mwy',
less: 'llai',
releases: 'Rhyddhau', releases: 'Rhyddhau',
killswitch: 'Killswitch', killswitch: 'Killswitch',
watchdog: 'Gwyliwr', watchdog: 'Gwyliwr',
@@ -186,6 +189,12 @@ window.localisation.we = {
"Caniatáu mynediad gan IP (bydd yn diystyru IPs sydd wedi'u blocio)", "Caniatáu mynediad gan IP (bydd yn diystyru IPs sydd wedi'u blocio)",
enter_ip: 'Rhowch IP a gwasgwch enter', enter_ip: 'Rhowch IP a gwasgwch enter',
rate_limiter: 'Cyfyngydd Cyfradd', rate_limiter: 'Cyfyngydd Cyfradd',
wallet_limiter: 'Cyfyngwr Waled',
wallet_limit_max_withdraw_per_day:
'Uchafswm tynnun ôl waled dyddiol mewn sats (0 i analluogi)',
wallet_max_ballance: 'Uchafswm balans y waled mewn sats (0 i analluogi)',
wallet_limit_secs_between_trans:
'Eiliadau lleiaf rhwng trafodion fesul waled (0 i analluogi)',
number_of_requests: 'Nifer y ceisiadau', number_of_requests: 'Nifer y ceisiadau',
time_unit: 'Uned amser', time_unit: 'Uned amser',
minute: 'munud', minute: 'munud',
@@ -193,5 +202,42 @@ window.localisation.we = {
hour: 'awr', hour: 'awr',
disable_server_log: 'Analluogi Log Gweinydd', disable_server_log: 'Analluogi Log Gweinydd',
enable_server_log: 'Galluogi Log Gweinydd', enable_server_log: 'Galluogi Log Gweinydd',
coming_soon: 'Nodwedd yn dod yn fuan' coming_soon: 'Nodwedd yn dod yn fuan',
session_has_expired: 'Mae eich sesiwn wedi dod i ben. Mewngofnodwch eto.',
instant_access_question: 'Eisiau mynediad ar unwaith?',
login_with_user_id: 'Mewngofnodi gyda ID y defnyddiwr',
or: 'neu',
create_new_wallet: 'Creu Waled Newydd',
login_to_account: "Mewngofnodwch i'ch cyfrif",
create_account: 'Creu cyfrif',
account_settings: 'Gosodiadau Cyfrif',
signin_with_google: 'Mewngofnodi gyda Google',
signin_with_github: 'Mewngofnodi gyda GitHub',
signin_with_keycloak: 'Mewngofnodi gyda Keycloak',
username_or_email: 'Defnyddiwr neu E-bost',
password: 'Cyfrinair',
password_config: 'Ffurfweddiad Cyfrinair',
password_repeat: 'Ailadrodd cyfrinair',
change_password: 'Newid Cyfrinair',
set_password: 'Gosod Cyfrinair',
invalid_password: "Rhaid i'r cyfrinair gynnwys o leiaf 8 nod.",
login: 'Mewngofnodi',
register: 'Cofrestru',
username: 'Enw defnyddiwr',
user_id: 'ID Defnyddiwr',
email: 'E-bost',
first_name: 'Enw Cyntaf',
last_name: 'Cyfenw',
picture: 'Llun',
verify_email: 'Gwirio e-bost gyda',
account: 'Cyfrif',
update_account: 'Diweddaru Cyfrif',
invalid_username: 'Enw Defnyddiwr Annilys',
auth_provider: 'Darparwr Dilysiad',
my_account: 'Fy Nghyfrif',
back: 'Yn ôl',
logout: 'Allgofnodi',
look_and_feel: 'Edrych a Theimlo',
language: 'Iaith',
color_scheme: 'Cynllun Lliw'
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+122
View File
@@ -0,0 +1,122 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
user: null,
hasUsername: false,
showUserId: false,
reactionOptions: [
'None',
'confettiBothSides',
'confettiFireworks',
'confettiStars'
],
tab: 'user',
passwordData: {
show: false,
oldPassword: null,
newPassword: null,
newPasswordRepeat: null
}
}
},
methods: {
activeLanguage: function (lang) {
return window.i18n.locale === lang
},
changeLanguage: function (newValue) {
window.i18n.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
toggleDarkMode: function () {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
},
reactionChoiceFunc: function () {
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
},
changeColor: function (newValue) {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
},
updateAccount: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/update',
null,
{
user_id: this.user.id,
username: this.user.username,
email: this.user.email,
config: this.user.config
}
)
this.user = data
this.$q.notify({
type: 'positive',
message: 'Account updated.'
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
updatePassword: async function () {
if (!this.user.username) {
this.$q.notify({
type: 'warning',
message: 'Please set a username first.'
})
return
}
try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/password',
null,
{
user_id: this.user.id,
username: this.user.username,
password_old: this.passwordData.oldPassword,
password: this.passwordData.newPassword,
password_repeat: this.passwordData.newPasswordRepeat
}
)
this.user = data
this.passwordData.show = false
this.$q.notify({
type: 'positive',
message: 'Password updated.'
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
showChangePassword: function () {
if (!this.user.username) {
this.$q.notify({
type: 'warning',
message: 'Please set a username first.'
})
return
}
this.passwordData = {
show: true,
oldPassword: null,
newPassword: null,
newPasswordRepeat: null
}
}
},
created: async function () {
try {
const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data
this.hasUsername = !!data.username
if (!this.user.config) this.user.config = {}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
})
+7 -11
View File
@@ -175,7 +175,7 @@ new Vue({
}, },
restartServer() { restartServer() {
LNbits.api LNbits.api
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id) .request('GET', '/admin/api/v1/restart/')
.then(response => { .then(response => {
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@@ -192,7 +192,7 @@ new Vue({
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
'/admin/api/v1/topup/?usr=' + this.g.user.id, '/admin/api/v1/topup/',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
this.wallet this.wallet
) )
@@ -229,11 +229,7 @@ new Vue({
}, },
getAudit() { getAudit() {
LNbits.api LNbits.api
.request( .request('GET', '/admin/api/v1/audit/', this.g.user.wallets[0].adminkey)
'GET',
'/admin/api/v1/audit/?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(response => { .then(response => {
this.auditData = response.data this.auditData = response.data
}) })
@@ -245,7 +241,7 @@ new Vue({
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/admin/api/v1/settings/?usr=' + this.g.user.id, '/admin/api/v1/settings/',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(response => { .then(response => {
@@ -266,7 +262,7 @@ new Vue({
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
'/admin/api/v1/settings/?usr=' + this.g.user.id, '/admin/api/v1/settings/',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
data data
) )
@@ -294,7 +290,7 @@ new Vue({
.confirmDialog('Are you sure you want to restore settings to default?') .confirmDialog('Are you sure you want to restore settings to default?')
.onOk(() => { .onOk(() => {
LNbits.api LNbits.api
.request('DELETE', '/admin/api/v1/settings/?usr=' + this.g.user.id) .request('DELETE', '/admin/api/v1/settings/')
.then(response => { .then(response => {
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@@ -310,7 +306,7 @@ new Vue({
}) })
}, },
downloadBackup() { downloadBackup() {
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank') window.open('/admin/api/v1/backup/', '_blank')
} }
} }
}) })
+126 -19
View File
@@ -49,14 +49,16 @@ window.LNbits = {
description_hash, description_hash,
amount, amount,
description = '', description = '',
comment = '' comment = '',
unit = ''
) { ) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback, callback,
description_hash, description_hash,
amount, amount,
comment, comment,
description description,
unit
}) })
}, },
authLnurl: function (wallet, callback) { authLnurl: function (wallet, callback) {
@@ -69,6 +71,41 @@ window.LNbits = {
name: name name: name
}) })
}, },
register: function (username, email, password, password_repeat) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
data: {
username,
email,
password,
password_repeat
}
})
},
login: function (username, password) {
return axios({
method: 'POST',
url: '/api/v1/auth',
data: {username, password}
})
},
loginUsr: function (usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/usr',
data: {usr}
})
},
logout: function () {
return axios({
method: 'POST',
url: '/api/v1/auth/logout'
})
},
getAuthenticatedUser: function () {
return this.request('get', '/api/v1/auth')
},
getWallet: function (wallet) { getWallet: function (wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey) return this.request('get', '/api/v1/wallet', wallet.inkey)
}, },
@@ -76,7 +113,7 @@ window.LNbits = {
return this.request('post', '/api/v1/wallet', wallet.adminkey, { return this.request('post', '/api/v1/wallet', wallet.adminkey, {
name: name name: name
}).then(res => { }).then(res => {
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id window.location = '/wallet?wal=' + res.data.id
}) })
}, },
updateWallet: function (name, wallet) { updateWallet: function (name, wallet) {
@@ -93,8 +130,7 @@ window.LNbits = {
} }
) )
}, },
getPayments: function (wallet, query) { getPayments: function (wallet, params) {
const params = new URLSearchParams(query)
return this.request( return this.request(
'get', 'get',
'/api/v1/payments/paginated?' + params, '/api/v1/payments/paginated?' + params,
@@ -107,6 +143,24 @@ window.LNbits = {
'/api/v1/payments/' + paymentHash, '/api/v1/payments/' + paymentHash,
wallet.inkey wallet.inkey
) )
},
updateBalance: function (credit, wallet_id) {
return LNbits.api
.request('PUT', '/admin/api/v1/topup/', null, {
amount: credit,
id: wallet_id
})
.then(_ => {
Quasar.Notify.create({
type: 'positive',
message: 'Success! Added ' + credit + ' sats to ' + wallet_id,
icon: null
})
return parseInt(credit)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
} }
}, },
events: { events: {
@@ -114,7 +168,6 @@ window.LNbits = {
let listener = ev => { let listener = ev => {
cb(JSON.parse(ev.data)) cb(JSON.parse(ev.data))
} }
this.listenersCount = this.listenersCount || {[wallet.inkey]: 0} this.listenersCount = this.listenersCount || {[wallet.inkey]: 0}
this.listenersCount[wallet.inkey]++ this.listenersCount[wallet.inkey]++
@@ -200,7 +253,7 @@ window.LNbits = {
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format( newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
newWallet.sat newWallet.sat
) )
newWallet.url = ['/wallet?usr=', data.user, '&wal=', data.id].join('') newWallet.url = `/wallet?&wal=${data.id}`
return newWallet return newWallet
}, },
payment: function (data) { payment: function (data) {
@@ -311,6 +364,24 @@ window.LNbits = {
return data return data
} }
}, },
prepareFilterQuery(tableConfig, props) {
if (props) {
tableConfig.pagination = props.pagination
}
let pagination = tableConfig.pagination
tableConfig.loading = true
const query = {
limit: pagination.rowsPerPage,
offset: (pagination.page - 1) * pagination.rowsPerPage,
sortby: pagination.sortBy ?? '',
direction: pagination.descending ? 'desc' : 'asc',
...tableConfig.filter
}
if (tableConfig.search) {
query.search = tableConfig.search
}
return new URLSearchParams(query)
},
exportCSV: function (columns, data, fileName) { exportCSV: function (columns, data, fileName) {
var wrapCsvValue = function (val, formatFn) { var wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val var formatted = formatFn !== void 0 ? formatFn(val) : val
@@ -372,6 +443,8 @@ window.windowMixin = {
data: function () { data: function () {
return { return {
toggleSubs: true, toggleSubs: true,
reactionChoice: 'confettiBothSides',
isUserAuthorized: false,
g: { g: {
offline: !navigator.onLine, offline: !navigator.onLine,
visibleDrawer: false, visibleDrawer: false,
@@ -386,21 +459,10 @@ window.windowMixin = {
}, },
methods: { methods: {
activeLanguage: function (lang) {
return window.i18n.locale === lang
},
changeLanguage: function (newValue) {
window.i18n.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
changeColor: function (newValue) { changeColor: function (newValue) {
document.body.setAttribute('data-theme', newValue) document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue) this.$q.localStorage.set('lnbits.theme', newValue)
}, },
toggleDarkMode: function () {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
},
copyText: function (text, message, position) { copyText: function (text, message, position) {
var notify = this.$q.notify var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () { Quasar.utils.copyToClipboard(text).then(function () {
@@ -409,9 +471,50 @@ window.windowMixin = {
position: position || 'bottom' position: position || 'bottom'
}) })
}) })
},
checkUsrInUrl: async function () {
try {
const params = new URLSearchParams(window.location.search)
const usr = params.get('usr')
if (!usr) {
return
}
if (!this.isUserAuthorized) {
await LNbits.api.loginUsr(usr)
}
params.delete('usr')
const cleanQueryPrams = params.size ? `?${params.toString()}` : ''
window.history.replaceState(
{},
document.title,
window.location.pathname + cleanQueryPrams
)
} finally {
this.isUserAuthorized = !!this.$q.cookies.get(
'is_lnbits_user_authorized'
)
}
},
logout: async function () {
LNbits.utils
.confirmDialog(
'Do you really want to logout?' +
' Please visit "My Account" page to check your credentials!'
)
.onOk(async () => {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
})
} }
}, },
created: function () { created: async function () {
if ( if (
this.$q.localStorage.getItem('lnbits.darkMode') == true || this.$q.localStorage.getItem('lnbits.darkMode') == true ||
this.$q.localStorage.getItem('lnbits.darkMode') == false this.$q.localStorage.getItem('lnbits.darkMode') == false
@@ -420,6 +523,9 @@ window.windowMixin = {
} else { } else {
this.$q.dark.set(true) this.$q.dark.set(true)
} }
this.reactionChoice =
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin'] this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
let locale = this.$q.localStorage.getItem('lnbits.lang') let locale = this.$q.localStorage.getItem('lnbits.lang')
@@ -495,6 +601,7 @@ window.windowMixin = {
this.g.extensions = extensions this.g.extensions = extensions
} }
await this.checkUsrInUrl()
} }
} }
+59 -4
View File
@@ -116,7 +116,7 @@ Vue.component('lnbits-extension-list', {
<q-item v-for="extension in userExtensions" :key="extension.code" <q-item v-for="extension in userExtensions" :key="extension.code"
clickable clickable
:active="extension.isActive" :active="extension.isActive"
tag="a" :href="[extension.url, '?usr=', user.id].join('')"> tag="a" :href="extension.url">
<q-item-section side> <q-item-section side>
<q-avatar size="md"> <q-avatar size="md">
<q-img <q-img
@@ -181,7 +181,7 @@ Vue.component('lnbits-manage', {
<q-list v-if="user" dense class="lnbits-drawer__q-list"> <q-list v-if="user" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('manage')"></q-item-label> <q-item-label header v-text="$t('manage')"></q-item-label>
<div v-if="user.admin"> <div v-if="user.admin">
<q-item v-if='showAdmin' clickable tag="a" :href="['/admin?usr=', user.id].join('')"> <q-item v-if='showAdmin' clickable tag="a" href="/admin">
<q-item-section side> <q-item-section side>
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon> <q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
</q-item-section> </q-item-section>
@@ -189,7 +189,7 @@ Vue.component('lnbits-manage', {
<q-item-label lines="1" class="text-caption" v-text="$t('server')"></q-item-label> <q-item-label lines="1" class="text-caption" v-text="$t('server')"></q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item v-if='showNode' clickable tag="a" :href="['/node?usr=', user.id].join('')"> <q-item v-if='showNode' clickable tag="a" href="/node">
<q-item-section side> <q-item-section side>
<q-icon name="developer_board" color="grey-5" size="md"></q-icon> <q-icon name="developer_board" color="grey-5" size="md"></q-icon>
</q-item-section> </q-item-section>
@@ -198,7 +198,7 @@ Vue.component('lnbits-manage', {
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<q-item clickable tag="a" :href="['/extensions?usr=', user.id].join('')"> <q-item clickable tag="a" href="/extensions">
<q-item-section side> <q-item-section side>
<q-icon name="extension" color="grey-5" size="md"></q-icon> <q-icon name="extension" color="grey-5" size="md"></q-icon>
</q-item-section> </q-item-section>
@@ -664,3 +664,58 @@ Vue.component('lnbits-dynamic-fields', {
this.formData = this.buildData(this.options, this.value) this.formData = this.buildData(this.options, this.value)
} }
}) })
Vue.component('lnbits-update-balance', {
mixins: [windowMixin],
props: ['wallet_id', 'callback'],
computed: {
denomination() {
return LNBITS_DENOMINATION
},
admin() {
return this.g.user.admin
}
},
data: function () {
return {
credit: 0
}
},
methods: {
updateBalance: function (credit) {
LNbits.api.updateBalance(credit, this.wallet_id).then(res => {
this.callback({value: res, wallet_id: this.wallet_id})
})
}
},
template: `
<q-btn
v-if="admin"
round
color="primary"
icon="add"
size="sm"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
filled
:label='$t("credit_label", { denomination: denomination })'
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip>
</q-btn>
`
})
@@ -50,7 +50,8 @@ Vue.component('lnbits-funding-sources', {
lnd_rest_endpoint: 'Endpoint', lnd_rest_endpoint: 'Endpoint',
lnd_rest_cert: 'Certificate', lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon', lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon' lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: 'Enable Route Hints'
} }
], ],
[ [
@@ -105,6 +106,14 @@ Vue.component('lnbits-funding-sources', {
alby_access_token: 'Key' alby_access_token: 'Key'
} }
], ],
[
'ZBDWallet',
'ZBD',
{
zbd_api_endpoint: 'Endpoint',
zbd_api_key: 'Key'
}
],
[ [
'OpenNodeWallet', 'OpenNodeWallet',
'OpenNode', 'OpenNode',
+560
View File
@@ -0,0 +1,560 @@
function eventReactionWebocket(event_id) {
localUrl = ''
reaction = localStorage.getItem('lnbits.reactions')
if (!reaction || reaction === 'None') {
return
}
if (location.protocol !== 'http:') {
localUrl = 'wss://' + location.host + '/api/v1/ws/' + event_id
} else {
localUrl = 'ws://' + location.host + '/api/v1/ws/' + event_id
}
connection = new WebSocket(localUrl)
connection.onmessage = function (e) {
try {
const parsedData = JSON.parse(e.data)
if (parsedData.payment.amount < 0) {
return
}
reaction = localStorage.getItem('lnbits.reactions')
if (reaction) {
window[reaction.split('|')[1]]()
}
} catch (e) {
console.log(e)
}
}
}
function confettiBothSides() {
document.getElementById('vue').disabled = true
var end = Date.now() + 2 * 1000
var colors = ['#FFD700', '#ffffff']
function frame() {
confetti({
particleCount: 2,
angle: 60,
spread: 55,
origin: {x: 0},
colors: colors,
zIndex: 999999
})
confetti({
particleCount: 2,
angle: 120,
spread: 55,
origin: {x: 1},
colors: colors,
zIndex: 999999
})
if (Date.now() < end) {
requestAnimationFrame(frame)
} else {
document.getElementById('vue').disabled = false
}
}
frame()
}
function confettiFireworks() {
var duration = 3 * 1000
var animationEnd = Date.now() + duration
var defaults = {startVelocity: 30, spread: 360, ticks: 60, zIndex: 0}
function randomInRange(min, max) {
return Math.random() * (max - min) + min
}
var interval = setInterval(function () {
var timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) {
return clearInterval(interval)
}
var particleCount = 5 * (timeLeft / duration)
// since particles fall down, start a bit higher than random
confetti({
...defaults,
particleCount,
origin: {x: randomInRange(0.1, 0.3), y: Math.random() - 0.2}
})
confetti({
...defaults,
particleCount,
origin: {x: randomInRange(0.7, 0.9), y: Math.random() - 0.2}
})
}, 250)
}
function confettiStars() {
var defaults = {
spread: 360,
ticks: 50,
gravity: 0,
decay: 0.94,
startVelocity: 30,
colors: ['FFE400', 'FFBD00', 'E89400', 'FFCA6C', 'FDFFB8']
}
function shoot() {
confetti({
...defaults,
particleCount: 40,
scalar: 1.2,
shapes: ['star']
})
confetti({
...defaults,
particleCount: 10,
scalar: 0.75,
shapes: ['circle']
})
}
setTimeout(shoot, 0)
setTimeout(shoot, 100)
setTimeout(shoot, 200)
setTimeout(shoot, 0)
setTimeout(shoot, 100)
setTimeout(shoot, 200)
}
!(function (t, e) {
!(function t(e, n, a, i) {
var o = !!(
e.Worker &&
e.Blob &&
e.Promise &&
e.OffscreenCanvas &&
e.OffscreenCanvasRenderingContext2D &&
e.HTMLCanvasElement &&
e.HTMLCanvasElement.prototype.transferControlToOffscreen &&
e.URL &&
e.URL.createObjectURL
)
function r() {}
function l(t) {
var a = n.exports.Promise,
i = void 0 !== a ? a : e.Promise
return 'function' == typeof i ? new i(t) : (t(r, r), null)
}
var c,
s,
u,
d,
f,
h,
g,
m,
b =
((u = Math.floor(1e3 / 60)),
(d = {}),
(f = 0),
'function' == typeof requestAnimationFrame &&
'function' == typeof cancelAnimationFrame
? ((c = function (t) {
var e = Math.random()
return (
(d[e] = requestAnimationFrame(function n(a) {
f === a || f + u - 1 < a
? ((f = a), delete d[e], t())
: (d[e] = requestAnimationFrame(n))
})),
e
)
}),
(s = function (t) {
d[t] && cancelAnimationFrame(d[t])
}))
: ((c = function (t) {
return setTimeout(t, u)
}),
(s = function (t) {
return clearTimeout(t)
})),
{frame: c, cancel: s}),
v =
((m = {}),
function () {
if (h) return h
if (!a && o) {
var e = [
'var CONFETTI, SIZE = {}, module = {};',
'(' + t.toString() + ')(this, module, true, SIZE);',
'onmessage = function(msg) {',
' if (msg.data.options) {',
' CONFETTI(msg.data.options).then(function () {',
' if (msg.data.callback) {',
' postMessage({ callback: msg.data.callback });',
' }',
' });',
' } else if (msg.data.reset) {',
' CONFETTI.reset();',
' } else if (msg.data.resize) {',
' SIZE.width = msg.data.resize.width;',
' SIZE.height = msg.data.resize.height;',
' } else if (msg.data.canvas) {',
' SIZE.width = msg.data.canvas.width;',
' SIZE.height = msg.data.canvas.height;',
' CONFETTI = module.exports.create(msg.data.canvas);',
' }',
'}'
].join('\n')
try {
h = new Worker(URL.createObjectURL(new Blob([e])))
} catch (t) {
return (
void 0 !== typeof console &&
'function' == typeof console.warn &&
console.warn('🎊 Could not load worker', t),
null
)
}
!(function (t) {
function e(e, n) {
t.postMessage({options: e || {}, callback: n})
}
;(t.init = function (e) {
var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n])
}),
(t.fire = function (n, a, i) {
if (g) return e(n, null), g
var o = Math.random().toString(36).slice(2)
return (g = l(function (a) {
function r(e) {
e.data.callback === o &&
(delete m[o],
t.removeEventListener('message', r),
(g = null),
i(),
a())
}
t.addEventListener('message', r),
e(n, o),
(m[o] = r.bind(null, {data: {callback: o}}))
}))
}),
(t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m))
m[e](), delete m[e]
})
})(h)
}
return h
}),
y = {
particleCount: 50,
angle: 90,
spread: 45,
startVelocity: 45,
decay: 0.9,
gravity: 1,
drift: 0,
ticks: 200,
x: 0.5,
y: 0.5,
shapes: ['square', 'circle'],
zIndex: 100,
colors: [
'#26ccff',
'#a25afd',
'#ff5e7e',
'#88ff5a',
'#fcff42',
'#ffa62d',
'#ff36ff'
],
disableForReducedMotion: !1,
scalar: 1
}
function p(t, e, n) {
return (function (t, e) {
return e ? e(t) : t
})(t && null != t[e] ? t[e] : y[e], n)
}
function M(t) {
return t < 0 ? 0 : Math.floor(t)
}
function w(t) {
return parseInt(t, 16)
}
function x(t) {
return t.map(C)
}
function C(t) {
var e = String(t).replace(/[^0-9a-f]/gi, '')
return (
e.length < 6 && (e = e[0] + e[0] + e[1] + e[1] + e[2] + e[2]),
{
r: w(e.substring(0, 2)),
g: w(e.substring(2, 4)),
b: w(e.substring(4, 6))
}
)
}
function k(t) {
;(t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight)
}
function I(t) {
var e = t.getBoundingClientRect()
;(t.width = e.width), (t.height = e.height)
}
function T(t, e, n, o, r) {
var c,
s,
u = e.slice(),
d = t.getContext('2d'),
f = l(function (e) {
function l() {
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
}
;(c = b.frame(function e() {
!a ||
(o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width ||
o.height ||
(n(t), (o.width = t.width), (o.height = t.height)),
d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) {
return (function (t, e) {
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
(e.wobble += 0.1),
(e.velocity *= e.decay),
(e.tiltAngle += 0.1),
(e.tiltSin = Math.sin(e.tiltAngle)),
(e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5),
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
var n = e.tick++ / e.totalTicks,
a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin,
o = e.wobbleX + e.random * e.tiltCos,
r = e.wobbleY + e.random * e.tiltSin
return (
(t.fillStyle =
'rgba(' +
e.color.r +
', ' +
e.color.g +
', ' +
e.color.b +
', ' +
(1 - n) +
')'),
t.beginPath(),
'circle' === e.shape
? t.ellipse
? t.ellipse(
e.x,
e.y,
Math.abs(o - a) * e.ovalScalar,
Math.abs(r - i) * e.ovalScalar,
(Math.PI / 10) * e.wobble,
0,
2 * Math.PI
)
: (function (t, e, n, a, i, o, r, l, c) {
t.save(),
t.translate(e, n),
t.rotate(o),
t.scale(a, i),
t.arc(0, 0, 1, r, l, c),
t.restore()
})(
t,
e.x,
e.y,
Math.abs(o - a) * e.ovalScalar,
Math.abs(r - i) * e.ovalScalar,
(Math.PI / 10) * e.wobble,
0,
2 * Math.PI
)
: (t.moveTo(Math.floor(e.x), Math.floor(e.y)),
t.lineTo(Math.floor(e.wobbleX), Math.floor(i)),
t.lineTo(Math.floor(o), Math.floor(r)),
t.lineTo(Math.floor(a), Math.floor(e.wobbleY))),
t.closePath(),
t.fill(),
e.tick < e.totalTicks
)
})(d, t)
})).length
? (c = b.frame(e))
: l()
})),
(s = l)
})
return {
addFettis: function (t) {
return (u = u.concat(t)), f
},
canvas: t,
promise: f,
reset: function () {
c && b.cancel(c), s && s()
}
}
}
function E(t, n) {
var a,
i = !t,
r = !!p(n || {}, 'resize'),
c = p(n, 'disableForReducedMotion', Boolean),
s = o && !!p(n || {}, 'useWorker') ? v() : null,
u = i ? k : I,
d = !(!t || !s) && !!t.__confetti_initialized,
f =
'function' == typeof matchMedia &&
matchMedia('(prefers-reduced-motion)').matches
function h(e, n, i) {
for (
var o,
r,
l,
c,
s,
d = p(e, 'particleCount', M),
f = p(e, 'angle', Number),
h = p(e, 'spread', Number),
g = p(e, 'startVelocity', Number),
m = p(e, 'decay', Number),
b = p(e, 'gravity', Number),
v = p(e, 'drift', Number),
y = p(e, 'colors', x),
w = p(e, 'ticks', Number),
C = p(e, 'shapes'),
k = p(e, 'scalar'),
I = (function (t) {
var e = p(t, 'origin', Object)
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
})(e),
E = d,
S = [],
F = t.width * I.x,
N = t.height * I.y;
E--;
)
S.push(
((o = {
x: F,
y: N,
angle: f,
spread: h,
startVelocity: g,
color: y[E % y.length],
shape:
C[
((c = 0),
(s = C.length),
Math.floor(Math.random() * (s - c)) + c)
],
ticks: w,
decay: m,
gravity: b,
drift: v,
scalar: k
}),
(r = void 0),
(l = void 0),
(r = o.angle * (Math.PI / 180)),
(l = o.spread * (Math.PI / 180)),
{
x: o.x,
y: o.y,
wobble: 10 * Math.random(),
velocity: 0.5 * o.startVelocity + Math.random() * o.startVelocity,
angle2D: -r + (0.5 * l - Math.random() * l),
tiltAngle: Math.random() * Math.PI,
color: o.color,
shape: o.shape,
tick: 0,
totalTicks: o.ticks,
decay: o.decay,
drift: o.drift,
random: Math.random() + 5,
tiltSin: 0,
tiltCos: 0,
wobbleX: 0,
wobbleY: 0,
gravity: 3 * o.gravity,
ovalScalar: 0.6,
scalar: o.scalar
})
)
return a ? a.addFettis(S) : (a = T(t, S, u, n, i)).promise
}
function g(n) {
var o = c || p(n, 'disableForReducedMotion', Boolean),
g = p(n, 'zIndex', Number)
if (o && f)
return l(function (t) {
t()
})
i && a
? (t = a.canvas)
: i &&
!t &&
((t = (function (t) {
var e = document.createElement('canvas')
return (
(e.style.position = 'fixed'),
(e.style.top = '0px'),
(e.style.left = '0px'),
(e.style.pointerEvents = 'none'),
(e.style.zIndex = t),
e
)
})(g)),
document.body.appendChild(t)),
r && !d && u(t)
var m = {width: t.width, height: t.height}
function b() {
if (s) {
var e = {
getBoundingClientRect: function () {
if (!i) return t.getBoundingClientRect()
}
}
return (
u(e),
void s.postMessage({resize: {width: e.width, height: e.height}})
)
}
m.width = m.height = null
}
function v() {
;(a = null),
r && e.removeEventListener('resize', b),
i && t && (document.body.removeChild(t), (t = null), (d = !1))
}
return (
s && !d && s.init(t),
(d = !0),
s && (t.__confetti_initialized = !0),
r && e.addEventListener('resize', b, !1),
s ? s.fire(n, m, v) : h(n, m, v)
)
}
return (
(g.reset = function () {
s && s.reset(), a && a.reset()
}),
g
)
}
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
})(
(function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
})(),
e,
!1
),
(t.confetti = e.exports)
})(window, {})

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