Compare commits

...
Author SHA1 Message Date
dni ⚡andGitHub ff24847980 feat: add nostr-sdk library (#3217) 2025-06-25 13:58:20 +03:00
659ec31edd CI: lnbits-boltz docker, for those who want instant payments + boltz funding source UX tweak (#3192)
Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-06-25 12:20:46 +02:00
edb7d66efc feat: Add trimmed mean filtering for exchange rate outlier detection (#3206)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 13:16:00 +03:00
efcc5e2148 feat: improved nostr pubkey field label & tooltip and set password tooltip (#3205)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 12:06:42 +02:00
dni ⚡andGitHub 55889abd88 chore: update poetry, disable nix workflow and python package updates (#3213) 2025-06-24 18:09:48 +02:00
dni ⚡andGitHub 063aa4fd06 chore: update to lnbits version 1.2.0-rc2 (#3216) 2025-06-24 17:38:58 +02:00
Tiago VasconcelosandGitHub 7c0a955b30 fix: error handling (#3214) 2025-06-24 18:27:28 +03:00
dni ⚡andGitHub b1a09af82c chore: update py version in lnbits.sh (#3215) 2025-06-24 18:25:33 +03:00
dni ⚡andGitHub 6d2f39d390 fix: rounding errors on eclair total balance (#3196) 2025-06-24 16:09:53 +02:00
Tiago VasconcelosandGitHub 18496f0dd9 feat: add QR code for wallet keys (#3198) 2025-06-23 14:04:21 +03:00
Tiago VasconcelosandGitHub acf6c732ad [feat] Add payment status filters (#3203) 2025-06-20 14:45:12 +03:00
Vlad StanandGitHub b39bd257e0 fix: wallets navigation (#3208) 2025-06-20 13:45:08 +03:00
dni ⚡andGitHub 9aeecd4dc0 chore: update to version v1.2.0-rc1 (#3202) 2025-06-17 20:05:22 +03:00
e750165cc3 Allow custom text and icon for Keycloak (#3181)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-06 13:20:55 +03:00
dni ⚡andGitHub 77906bc817 feat: more verbose aes decrypt function (#3177) 2025-05-30 18:49:11 +03:00
Vlad StanandGitHub 63e728710d fix: accept soft deleted wallets (#3179) 2025-05-30 18:48:23 +03:00
blackcoffeexbtandGitHub 27fd510142 Add funding sources comparison table (#3183) 2025-05-29 13:45:08 +03:00
Vlad StanandGitHub e6de66e1b1 fix: handle node absent in 1ml.com (#3180) 2025-05-27 17:02:15 +03:00
dni ⚡andGitHub 4071925f65 feat: mask unexcepted error and add a exception id (#3178) 2025-05-27 12:37:38 +03:00
dni ⚡andGitHub 3c4d186dba feat: add urlsafe enc to lnbits-cli (#3173) 2025-05-27 11:51:26 +03:00
Vlad StanandGitHub 56aebb9d8f feat: better service fee payment memo (#3176) 2025-05-27 11:50:39 +03:00
Vlad StanandGitHub beee24bd92 [feat] ui support for high number of wallets and payments (#3174) 2025-05-27 11:41:25 +03:00
Vlad StanandGitHub 375b95c004 fix: allow ports for domains (#3171) 2025-05-26 11:35:02 +03:00
Tiago VasconcelosandGitHub 5345ccaf4e [Fix] QR readability (#3163) 2025-05-20 13:06:53 +03:00
ArcandGitHub efc5233399 fix: frontend scroll area on admin settings (#3162) 2025-05-20 11:52:07 +03:00
ArcandGitHub e4d09c6d12 docs: passing vars to appimage (#3164) 2025-05-20 11:51:52 +03:00
Vlad StanandGitHub 7d0545dae1 [fix] timezone for payment list (#3165) 2025-05-20 11:51:32 +03:00
00fccf513e feat: Add Strike Wallet Integration (#3150)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-20 11:51:05 +03:00
dni ⚡andGitHub 34b8490a2d test: conftest smaller funding amounts (#3167) 2025-05-20 11:25:08 +03:00
dni ⚡andGitHub cbbba5c4c7 fix: regtest nodemanager lndrest issue (#3166) 2025-05-20 09:23:59 +02:00
dni ⚡andGitHub 4a0ef7fa1a feat: add ssl proxy settings to docker and .env.example (#3161) 2025-05-19 12:47:30 +01:00
dni ⚡andGitHub cd8804daca chore: update to version v1.1.0 (#3157) 2025-05-13 13:34:47 +02:00
39e4fa724a test: additional cases for internal payments (#3155)
Co-authored-by: dni  <office@dnilabs.com>
2025-05-13 13:48:47 +03:00
dni ⚡andGitHub 3b350858c7 refactor: untangle lnd's macaroon encryption with AESCipher class (#3152) 2025-05-13 12:07:38 +02:00
7bea591879 feat: dont reset superuser on delete settings endpoint (#3065)
Co-authored-by: dni  <office@dnilabs.com>
2025-05-13 12:52:59 +03:00
Vlad StanandGitHub f74fcea35b fix: safe json conversion (#3148) 2025-05-13 12:51:58 +03:00
ceb8203353 refactor: render variables for jinja, components and window vars (#3014)
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-13 11:49:46 +02:00
SatandGitHub 7b29223e95 feat: add verify_preimage utility function (#3153) 2025-05-13 12:47:08 +03:00
Se7enZandGitHub bd19d78834 build: add POETRY_INSTALL_ARGS build argument to Dockerfile (#3149) 2025-05-11 04:47:45 +02:00
Tiago VasconcelosandGitHub 8458b4d84b fix: do not ask for NFC if not needed (#3145) 2025-05-05 22:23:32 +02:00
Vlad StanandGitHub f92ae8bae1 doc: add time unit to payment_wait_time (#3144) 2025-05-05 13:39:00 +03:00
Vlad StanandGitHub 3529f9152f fix: better differentiation between UNAUTHORIZED and FORBIDDEN (#3139) 2025-05-05 10:55:58 +02:00
dni ⚡andGitHub 6a9089fd98 fix: lnd/lndrest failed canceled/expired invoices (#3143) 2025-05-05 11:31:11 +03:00
tlindiandGitHub 5818c3c2ba [doc] Update installation.md to match v1.0.x requirements (#3107) 2025-05-04 09:37:03 +02:00
dni ⚡andGitHub a38de94e49 chore: update to v1.0.1 (#3137) 2025-05-02 17:01:20 +03:00
tlindiandGitHub b4801ce0ab Update fi.js Password reset request (+fix "more") (#3138) 2025-05-02 10:46:10 +03:00
51bf344d65 [FIX] Fix database compatibility issue in search query builder for payments filter (#3090)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-02 10:45:35 +03:00
Vlad StanandGitHub 32cbf16d91 fix: _bcrypt.__about__.__version__ log warning (#3136) 2025-04-30 20:44:55 +03:00
c4d0540e76 feat: preimages for incoming payments, fundingsource saves preimage on create_invoice (#3085)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-04-30 11:03:19 +02:00
Vlad StanandGitHub f8b3644029 fix: auto redirect to home page when 401 (#3131) 2025-04-29 15:28:04 +03:00
dni ⚡andGitHub c2fb45d640 docs: add notes about freeBSD install (#3124) 2025-04-29 13:55:07 +03:00
Vlad StanandGitHub e339bb6181 [feat] fetch all payments for user (#3132) 2025-04-29 13:52:07 +03:00
Vlad StanandGitHub 2dee26b728 fix: set explicit max_age for cookies (#3133) 2025-04-29 11:16:37 +03:00
dni ⚡andGitHub d774c7a742 security: update package h11 (#3127) 2025-04-28 15:32:32 +02:00
Vlad StanandGitHub 6c2b312c92 fix: webhook call on invoice pay (#3119) 2025-04-28 12:28:27 +03:00
iwarpandGitHub 56c8783d9a fix: Normalize fee_reserve amount value (#3125) 2025-04-28 12:27:40 +03:00
dni ⚡andGitHub ffecd03c4b refactor: fundingsource Invoice-, PaymentResponses (#3089) 2025-04-25 11:52:54 +02:00
Vlad StanandGitHub 94d5f37723 fix: mask for fiat amount (#3118) 2025-04-24 08:11:39 +02:00
Vlad StanandGitHub d89bd7409d fix: use UTC for fromNow() (#3105) 2025-04-17 10:05:05 +03:00
iwarpandGitHub b48489ef32 fix: Explicitly convert lndrest fee_msat to int (#3116) 2025-04-17 09:48:06 +03:00
Vlad StanandGitHub e686ecda51 feat: i18n reset password message (#3106) 2025-04-14 13:25:41 +03:00
Vlad StanandGitHub 23b4c2cc16 [fix] offset naive dates error (#3104) 2025-04-14 13:21:16 +03:00
Tiago VasconcelosandGitHub 1bb29cf0b1 bug: add the defaults else it fails (#3099) 2025-04-12 09:47:00 +02:00
dni ⚡andGitHub 5714848434 feat: install lnbits.sh bash script (#2684) (#3101) 2025-04-10 12:28:08 +02:00
dni ⚡andGitHub f87e5d4372 chore: update LNbits to version v1.0.0 (#3100) 2025-04-10 12:09:13 +02:00
Tiago VasconcelosandGitHub a0502f1676 [Fix] - Bug on switching wallets and payment details (#3094) 2025-04-10 10:02:35 +01:00
Vlad StanandGitHub f6c8a308dc feat: create new user from the command line (#3098) 2025-04-09 14:01:34 +02:00
dni ⚡andGitHub 6df3933c1b ci: trigger release on rc's > 9 (#3088) 2025-04-03 13:12:19 +02:00
dni ⚡andGitHub ffef085fa7 chore: update to v1.0.0-rc10 (#3087) 2025-04-03 12:55:30 +02:00
tlindiandGitHub 913991c72f Update fi.js-1.0.0-rc10-tobe (#3086) 2025-04-03 10:31:49 +03:00
dni ⚡andGitHub 6f611461f2 fix: FakeWallet, bolt11 payment_secret is not the preimage (#3084) 2025-04-02 18:46:24 +03:00
Tiago VasconcelosandGitHub 681385e2a2 fix dialog consistency (#3082) 2025-04-02 16:03:40 +02:00
dni ⚡andGitHub 883d52c303 fix: phoenixd uses sat instead of msat for pay_invoice (#3083) 2025-04-02 16:49:59 +03:00
Vlad StanandGitHub 1058461882 fix: include node balance in server status (#3079) 2025-04-02 15:31:47 +03:00
Vlad StanandGitHub 3a7a88857a fix: missing label (#3081) 2025-04-02 14:51:15 +03:00
Vlad StanandGitHub 6834b5e00f feat: do not allow regular admins to edit super user (#3077) 2025-04-01 14:01:02 +03:00
Vlad StanandGitHub 10093bb465 feat: show wallet payments on search (#3076) 2025-04-01 12:41:10 +02:00
dni ⚡andGitHub 1323a2005b feat: cleanup on library dir creation and upload endpoints (#3069) 2025-04-01 10:27:11 +03:00
Vlad StanandGitHub bafb4ddf75 fix: allow admin to view payments in deleted wallets (#3074) 2025-03-31 17:18:32 +03:00
Vlad StanandGitHub aa050eaf49 fix: make parallel calls for exchages (#3073) 2025-03-31 14:44:36 +03:00
ArcandGitHub 0c76efa2b9 Add ws link to curl examples (#3063) 2025-03-31 12:58:26 +03:00
PatMulliganandGitHub 681730a4af [CHORE] add responsive text scaling with clamp() for wallet balance display (#3071) 2025-03-31 11:59:58 +03:00
ArcandGitHub 63adcb6780 Adds image library for admins (#3066) 2025-03-27 11:16:20 +02:00
dni ⚡andGitHub 35f7821183 fix: make swagger understand the access token HTTPBearer (#3068) 2025-03-27 09:14:42 +00:00
Vlad StanandGitHub b185d9585c fix: add default extensions when a user is created (#3061) 2025-03-22 23:54:15 +00:00
ArcandGitHub 30a8d88ada Remove LNbits UI elements (#3064) 2025-03-22 21:08:26 +00:00
ArcandGitHub f7e984198b Revert "tweak: fix for ws payment" (#3060) 2025-03-21 14:03:52 +02:00
ArcandGitHub eb09e95e88 tweak: fix for ws payment (#3057) 2025-03-20 23:51:10 +00:00
Tiago VasconcelosandGitHub a883e2c7d8 hide 'Runs On' scroll if title is not LNbits (#3053) 2025-03-18 10:19:10 +01:00
Tiago VasconcelosandGitHub e06abb52ce [Fix] default to sats if wallet not fakewallet (#3055) 2025-03-18 10:17:47 +01:00
tlindiandGitHub 9f64b5349d Dev update fi.js again (#3052) 2025-03-18 11:07:37 +02:00
ArcandGitHub a6bbddce41 tweak: use image that works for fav and qr as default (#3051) 2025-03-14 10:48:58 +01:00
Tiago VasconcelosandGitHub adb9d24673 [Fix] Set default chart text color (#3048)
* fix: set default chart text
white when darkMode else black
2025-03-12 17:36:11 +01:00
Tiago VasconcelosandGitHub dc85f26964 fix: qr scanner button was missing the method (#3046) 2025-03-12 16:09:53 +01:00
Tiago VasconcelosandGitHub 14153b4a8f [Fix] Remove references and warnings for BETA (#3047)
* fix: remove BETA references/warnings
2025-03-12 16:09:18 +01:00
Vlad StanandGitHub b8a5f3942c [fix] limit the size of the invoice description to 640 characters (#3045) 2025-03-12 16:29:29 +02:00
dni ⚡andGitHub 475ae5736e chore: update to v1.0.0-rc9 (#3043) 2025-03-12 12:29:01 +01:00
PatMulliganandGitHub d7f0f4da0c create backwards compatibility for <=v1.0 (#3041)
in v0.12.12, the key is "payment_request" instead of the new "bolt11"
2025-03-12 12:27:24 +01:00
Tiago VasconcelosandGitHub f7b3444be4 fix: unfuck the swap mess (#3023) 2025-03-12 12:14:20 +01:00
ArcandGitHub 28e32a54a4 tweak: use qr code image for favicon as well (#3031)
use qr code image for favicon as well
2025-03-12 12:12:43 +01:00
ArcandGitHub 1833adc0a1 Tweak: moved custom denomination to fakewallet funding source (#3030) 2025-03-12 12:10:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
b759ec7468 chore(deps): bump axios from 1.7.7 to 1.8.2 (#3042)
* chore(deps): bump axios from 1.7.7 to 1.8.2

Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:10:27 +01:00
2661d12ff1 tweak: fixes service fee badge (#3029)
* tweak: fixes service fee badge

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:08:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
ed419f27ac chore(deps): bump vue-i18n from 10.0.5 to 10.0.6 (#3022)
* chore(deps): bump vue-i18n from 10.0.5 to 10.0.6

Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.5 to 10.0.6.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.6/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:01:18 +01:00
dni ⚡andGitHub c3efd48108 chore: use latest 1.X poetry version (#3039) 2025-03-11 09:54:36 +01:00
ArcandGitHub 4a28c22d79 tweak: fix 'test email' btn (#3026) 2025-03-10 18:37:11 +00:00
ArcandGitHub 0d0eb36de4 tweak: fix funding source hide (#3027)
added to title rather than all the inputs
2025-03-10 18:36:48 +00:00
ArcandGitHub 2aa73bfe75 tweak: punchy bitcoin theme (#3028) 2025-03-10 18:36:23 +00:00
Vlad StanandGitHub ebd080192c feat: return payment status in the websocket response (#3025) 2025-03-10 12:48:40 +02:00
Tiago VasconcelosandGitHub 41abe63f18 hide text on small screen (#3021) 2025-03-07 14:23:46 +00:00
dni ⚡andGitHub f61471b0f2 feat: add test email and improve on email notifications (#3018) 2025-03-05 09:53:40 +02:00
Vlad StanandGitHub 9bd037b6e7 [fix] settings cleanup (#3013) 2025-03-04 16:30:54 +02:00
a4ca88b6a4 tweak: dont show full id on wallet select box (#3017)
* tweak: dont show full id on wallet select box
* chore bundle

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-04 11:23:02 +01:00
ArcandGitHub f2b9aafc51 Tweak: Changed naming for password area (#3016) 2025-03-04 11:13:41 +01:00
137 changed files with 7439 additions and 4127 deletions
+13 -5
View File
@@ -40,7 +40,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -101,10 +101,16 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
@@ -163,14 +169,16 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
+1 -6
View File
@@ -5,9 +5,6 @@ inputs:
description: "Python Version"
required: true
default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
node-version:
description: "Node Version"
default: "20.x"
@@ -27,10 +24,8 @@ runs:
# cache poetry install via pip
cache: "pip"
- name: Set up Poetry ${{ inputs.poetry-version }}
- name: Set up Poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
-1
View File
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
export LNBITS_ADMIN_UI=true
# Define the LNbits URL
URL="http://0.0.0.0:5000"
-2
View File
@@ -88,8 +88,6 @@ jobs:
strategy:
matrix:
python-version: ["3.10"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+11
View File
@@ -51,3 +51,14 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
-5
View File
@@ -8,11 +8,6 @@ on:
required: true
default: "3.10"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
jmeter:
+1
View File
@@ -25,6 +25,7 @@ on:
jobs:
nix:
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -3,7 +3,7 @@ name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
- "*-rc[0-9]+"
permissions:
contents: write
+1
View File
@@ -7,6 +7,7 @@ __pycache__
.mypy_cache
.vscode
*-lock.json
.python-version
*.egg
*.egg-info
+8 -5
View File
@@ -4,13 +4,14 @@ RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data
@@ -19,7 +20,8 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
RUN poetry install --only main
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
FROM python:3.12-slim-bookworm
@@ -32,7 +34,7 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
@@ -46,11 +48,12 @@ WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
RUN poetry install --only main
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+30
View File
@@ -0,0 +1,30 @@
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
# Reinstall dependencies just in case (needed for CMD usage)
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/dockerboltz.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
boltzd --standalone --referralId lnbits &
# Capture boltzd PID to monitor if needed
BOLTZ_PID=$!
# Wait for boltzd to start
for i in {1..10}; do
if nc -z localhost 9002; then
echo "boltzd is up!"
break
fi
echo "Waiting for boltzd to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost 9002; then
echo "boltzd did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+30
View File
@@ -0,0 +1,30 @@
# LNbits Funding Sources Comparison Table
LNbits can use a number of different Lightning Network funding source.
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
+53 -11
View File
@@ -18,7 +18,7 @@ Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppI
sudo apt-get install libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
@@ -29,11 +29,13 @@ It is recommended to use the latest version of Poetry. Make sure you have Python
### Install Python 3.12
## Option 2 (recommended): Poetry
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### Verify Python version
```sh
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update -y
sudo apt install -y python3.12 python3.12-dev # ensure correct headers needed for secp256k1
sudo apt install -y pkg-config python3-dev build-essential # ensure correct headers
python3 --version
```
@@ -76,13 +78,38 @@ poetry run lnbits-cli --help
```sh
cd lnbits
# Stop LNbits with `ctrl + x`
git pull
# Keep your poetry install up to date, this can be done with `poetry self update`
# Stop LNbits with `ctrl + x` or with service manager
# sudo systemctl stop lnbits
# Update LNbits
git pull --rebase
# Check your poetry version with
poetry env list
# If version is less 3.12, update it by running
poetry env use python3.12
poetry env remove python3.9
poetry env list
# Run install and start LNbits with
poetry install --only main
# Start LNbits with `poetry run lnbits`
poetry run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
```
## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh
@@ -123,7 +150,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
## Option 4: Docker
use latest version from docker hub
Use latest version from Docker Hub.
```sh
docker pull lnbits/lnbits
@@ -132,7 +159,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
build the image yourself
Build the image yourself.
```sh
git clone https://github.com/lnbits/lnbits.git
@@ -143,6 +170,12 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
```sh
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
@@ -550,3 +583,12 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
```
Finally you can access your lnbits on your machine at port 5000.
### FreeBSD notes
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
3. Rewrite `poetry.lock` file with command `poetry lock`
4. Follow install instruction with Poetry
+3 -1
View File
@@ -10,6 +10,8 @@ LNbits can run on top of many Lightning Network funding sources with more being
A backend wallet can be configured using the following LNbits environment variables:
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -51,7 +53,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
### LNbits
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Check install has not already run
if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.10 and distutils non-interactively
sudo apt install -y python3.10 python3.10-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.10 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
+7 -3
View File
@@ -163,9 +163,13 @@ def create_app() -> FastAPI:
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
static_path = Path("lnbits", "static")
static = StaticFiles(directory=static_path)
app.mount("/static", static, name="static")
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
app.mount(
"/library",
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
name="library",
)
g().base_url = f"http://{settings.host}:{settings.port}"
+110 -14
View File
@@ -3,8 +3,10 @@ import importlib
import sys
import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from typing import Optional
from uuid import uuid4
import click
import httpx
@@ -27,19 +29,21 @@ from lnbits.core.crud import (
update_payment,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.models import Account, Payment, PaymentState
from lnbits.core.models.extensions import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.services import check_admin_settings
from lnbits.core.services import check_admin_settings, create_user_account_no_ckeck
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.wallets.base import Wallet
from lnbits.wallets.macaroon import load_macaroon
def coro(f):
@@ -64,6 +68,13 @@ def db():
"""
@lnbits_cli.group()
def users():
"""
Users related commands
"""
@lnbits_cli.group()
def extensions():
"""
@@ -71,6 +82,20 @@ def extensions():
"""
@lnbits_cli.group()
def encrypt():
"""
Encryption commands
"""
@lnbits_cli.group()
def decrypt():
"""
Decryption commands
"""
def get_super_user() -> Optional[str]:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
@@ -109,7 +134,7 @@ async def delete_settings():
"""Deletes the settings"""
async with core_db.connect() as conn:
await conn.execute("DELETE from settings")
await conn.execute("DELETE from system_settings")
@db.command("migrate")
@@ -180,17 +205,6 @@ async def database_revert_payment(checking_id: str):
click.echo(f"Payment '{checking_id}' marked as pending.")
@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)
@db.command("check-payments")
@click.option("-d", "--days", help="Maximum age of payments in days.")
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
@@ -271,6 +285,32 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
@users.command("new")
@click.option("-u", "--username", required=True, help="Username.")
@click.option("-p", "--password", required=True, help="Password.")
@coro
async def create_user(username: str, password: str):
"""Create a new user bypassing the system 'new_accounts_allowed' rules"""
account = Account(
id=uuid4().hex,
username=username,
)
account.hash_password(password)
user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
@users.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)
@extensions.command("list")
@coro
async def extensions_list():
@@ -456,6 +496,62 @@ async def extensions_uninstall(
return False, str(ex)
@encrypt.command("macaroon")
def encrypt_macaroon():
"""Encrypts a macaroon (LND wallets)"""
_macaroon = getpass("Enter macaroon: ")
try:
macaroon = load_macaroon(_macaroon)
except Exception as ex:
click.echo(f"Error loading macaroon: {ex}")
return
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
except Exception as ex:
click.echo(f"Error encrypting macaroon: {ex}")
return
click.echo("Encrypted macaroon: ")
click.echo(encrypted_macaroon)
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
click.echo("Encrypted payload: ")
click.echo(encrypted)
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
click.echo("Decrypted payload: ")
click.echo(decrypted)
def main():
"""main function"""
lnbits_cli()
+2
View File
@@ -43,6 +43,7 @@ from .settings import (
delete_admin_settings,
get_admin_settings,
get_super_settings,
reset_core_settings,
update_admin_settings,
update_super_user,
)
@@ -129,6 +130,7 @@ __all__ = [
"get_super_settings",
"update_admin_settings",
"update_super_user",
"reset_core_settings",
# tinyurl
"create_tinyurl",
"delete_tinyurl",
+47 -12
View File
@@ -1,7 +1,7 @@
from time import time
from typing import Optional, Tuple
from typing import Any, Optional, Tuple
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page
@@ -95,6 +95,7 @@ async def get_latest_payments_by_extension(
async def get_payments_paginated(
*,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
@@ -110,8 +111,7 @@ async def get_payments_paginated(
- complete | pending | failed | outgoing | incoming.
"""
values: dict = {
"wallet_id": wallet_id,
values: dict[str, Any] = {
"time": since,
}
clause: list[str] = []
@@ -120,7 +120,11 @@ async def get_payments_paginated(
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending:
clause.append(
@@ -358,12 +362,19 @@ async def get_payments_history(
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
extra_stmts = []
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count(*) as total
@@ -381,18 +392,26 @@ async def get_payment_count_stats(
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_clause = filters.where(
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
)
out_clause = filters.where(
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
)
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
out_where_stmts = [
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
@@ -423,6 +442,7 @@ async def get_daily_stats(
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
@@ -441,6 +461,10 @@ async def get_wallets_stats(
)
""",
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
@@ -508,7 +532,7 @@ async def is_internal_status_success(
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
@@ -516,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: int) -> None:
""",
{"status": status, "hash": payment_hash},
)
async def _only_user_wallets_statement(
user_id: str, conn: Optional[Connection] = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
+17 -1
View File
@@ -61,7 +61,23 @@ async def update_super_user(super_user: str) -> SuperSettings:
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
)
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
+39 -1
View File
@@ -4,7 +4,8 @@ from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
from ..models import Wallet
@@ -135,6 +136,43 @@ async def get_wallets(
)
async def get_wallets_paginated(
user_id: str,
deleted: Optional[bool] = None,
filters: Optional[Filters[WalletsFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""",
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
)
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
where = "AND deleted = :deleted" if deleted is not None else ""
result: list[dict] = await (conn or db).fetchall(
f"""
SELECT id FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted},
)
return [row["id"] for row in result]
async def get_wallets_count():
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
row = result.mappings().first()
+5
View File
@@ -46,3 +46,8 @@ class SimpleItem(BaseModel):
class DbVersion(BaseModel):
db: str
version: int
class Image(BaseModel):
filename: str
directory: str = "library"
+2 -1
View File
@@ -29,7 +29,8 @@ NOTIFICATION_TEMPLATES = {
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.""",
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.""",
"server_start_stop": """*SERVER*
{message}
*Time*: `{up_time}` seconds.
+2 -2
View File
@@ -64,7 +64,7 @@ class Payment(BaseModel):
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: int | None = None
webhook_status: str | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
@@ -198,7 +198,7 @@ class CreateInvoice(BaseModel):
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = None
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
expiry: int | None = None
+3
View File
@@ -27,6 +27,9 @@ class UserExtra(BaseModel):
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
class EndpointAccess(BaseModel):
path: str
+12
View File
@@ -9,6 +9,7 @@ from enum import Enum
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
class Wallet(BaseModel):
@@ -83,3 +85,13 @@ class KeyType(Enum):
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None
-2
View File
@@ -24,7 +24,6 @@ from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
@@ -58,7 +57,6 @@ __all__ = [
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
+52 -34
View File
@@ -24,7 +24,7 @@ from lnbits.core.models.notifications import (
)
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
@@ -111,41 +111,56 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
return response.json()
async def send_email_notification(message: str) -> dict:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
"LNbits Notification",
message,
)
return {"status": "ok"}
async def send_email_notification(
message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
try:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
subject,
message,
)
return {"status": "ok"}
except Exception as e:
logger.error(f"Error sending email notification: {e}")
return {"status": "error", "message": str(e)}
async def send_email(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list,
to_emails: list[str],
subject: str,
message: str,
):
) -> bool:
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if len(to_emails) == 0:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
try:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(from_email, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
except Exception as e:
logger.debug(f"Failed to send email: {e}")
username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
@@ -190,24 +205,23 @@ async def dispatch_webhook(payment: Payment):
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, -1)
return await mark_webhook_sent(payment.payment_hash, "-1")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Could not send webhook to {payment.webhook}")
@@ -215,18 +229,21 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending websocket payment notification", e)
logger.error(f"Error sending websocket payment notification {e!s}")
try:
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending chat payment notification", e)
logger.error(f"Error sending chat payment notification {e!s}")
try:
await send_payment_push_notification(wallet, payment)
except Exception as e:
logger.error("Error sending push payment notification", e)
logger.error(f"Error sending push payment notification {e!s}")
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
try:
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
except Exception as e:
logger.error(f"Error dispatching webhook: {e!s}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
@@ -246,7 +263,8 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
)
+62 -29
View File
@@ -44,6 +44,9 @@ from ..models import (
)
from .notifications import send_payment_notification
payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
async def pay_invoice(
*,
@@ -79,12 +82,16 @@ async def pay_invoice(
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, new_conn)
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
return payment
return payment
async def create_invoice(
@@ -108,7 +115,7 @@ async def create_invoice(
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
@@ -132,29 +139,29 @@ async def create_invoice(
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
payment_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
if (
not payment_response.ok
or not payment_response.payment_request
or not payment_response.checking_id
):
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
message=payment_response.error_message or "unexpected backend error.",
status="pending",
)
invoice = bolt11_decode(payment_request)
invoice = bolt11_decode(payment_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
bolt11=payment_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=payment_response.preimage,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
@@ -163,7 +170,7 @@ async def create_invoice(
)
payment = await create_payment(
checking_id=checking_id,
checking_id=payment_response.checking_id,
data=create_payment_model,
conn=conn,
)
@@ -199,6 +206,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
return settings.fee_reserve(amount_msat, internal)
@@ -395,8 +403,9 @@ async def check_transaction_status(
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
user_id: Optional[str] = None,
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters)
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
balance_total: float = 0
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
@@ -409,13 +418,16 @@ async def get_payments_daily_stats(
data: list[PaymentDailyStats] = []
start_date = min(data_in[0].date, data_out[0].date)
end_date = max(data_in[-1].date, data_out[-1].date)
def _tz(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc)
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
delta = timedelta(days=1)
while start_date <= end_date:
data_in_point = next((x for x in data_in if x.date == start_date), _none)
data_out_point = next((x for x in data_out if x.date == start_date), _none)
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none)
balance_total += data_in_point.balance + data_out_point.balance
data.append(
@@ -437,11 +449,27 @@ async def get_payments_daily_stats(
return data
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_invoice(
wallet_id: str,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
):
async with payment_lock:
if wallet_id not in wallets_payments_lock:
wallets_payments_lock[wallet_id] = asyncio.Lock()
async with wallets_payments_lock[wallet_id]:
# get the wallet again to make sure we have the latest balance
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(
f"Could not fetch wallet '{wallet_id}'.", status="failed"
)
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_internal_invoice(
@@ -476,12 +504,15 @@ async def _pay_internal_invoice(
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
# release the preimage
create_payment_model.preimage = internal_invoice.preimage
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
@@ -519,6 +550,8 @@ async def _pay_external_invoice(
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat):
raise PaymentError("Insufficient balance.", status="failed")
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
@@ -676,7 +709,7 @@ def _validate_payment_request(
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, memo: str, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
@@ -687,7 +720,7 @@ async def _credit_service_fee_wallet(
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
+10 -13
View File
@@ -8,7 +8,6 @@ from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
@@ -47,7 +46,9 @@ async def create_user_account(
async def create_user_account_no_ckeck(
account: Optional[Account] = None, wallet_name: Optional[str] = None
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
) -> User:
if account:
@@ -70,9 +71,13 @@ async def create_user_account_no_ckeck(
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
for ext_id in user_extensions:
try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
@@ -154,14 +159,6 @@ async def check_admin_settings():
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
@@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -150,7 +150,6 @@
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
@@ -194,6 +193,24 @@
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_username')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_username_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_username"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
@@ -212,7 +229,17 @@
/>
</q-item-section>
</q-item>
<q-item>
<q-btn
@click="sendTestEmail()"
:label="$t('notifications_send_test_email')"
color="primary"
class="q-mt-md"
></q-btn>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
@@ -249,9 +276,6 @@
</div>
</q-item-section>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
+19 -3
View File
@@ -132,8 +132,8 @@
>
<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">
<div class="row q-col-gutter-sm q-col-gutter-y-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_discovery_url"
@@ -141,7 +141,7 @@
>
</q-input>
</div>
<div class="col-md-4 col-sm-12 q-pr-sm">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_id"
@@ -159,6 +159,22 @@
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_org"
:label="$t('auth_keycloak_custom_org_label')"
>
</q-input>
</div>
<div class="col-md-8 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_icon"
:label="$t('auth_keycloak_custom_icon_label')"
>
</q-input>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
+1 -12
View File
@@ -10,7 +10,7 @@
filled
type="text"
v-model="formData.lnbits_site_title"
:label="$t('ui_site_title')"
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
></q-input>
<br />
</div>
@@ -53,17 +53,6 @@
:label="$t('lnbits_wallet')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('denomination')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
:hint="$t('denomination_hint')"
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input
+28 -16
View File
@@ -149,6 +149,14 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
@@ -163,22 +171,26 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
</q-tab-panels>
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
+69 -8
View File
@@ -48,6 +48,17 @@
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
@@ -70,6 +81,17 @@
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
@@ -85,7 +107,12 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -96,7 +123,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="wallet.inkey"></i>"</code
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
@@ -112,7 +140,12 @@
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -130,8 +163,9 @@
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -218,7 +252,12 @@
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -227,11 +266,33 @@
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
+27 -5
View File
@@ -45,7 +45,7 @@
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password_config')"></span>
<span v-text="$t('password')"></span>
</h4>
</div>
<div class="col">
@@ -102,7 +102,7 @@
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
:label="$t('update_password')"
>
</q-btn>
</q-card-section>
@@ -110,7 +110,7 @@
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
Nostr <span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
@@ -287,7 +287,7 @@
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
:label="$t('change_password')"
filled
color="primary"
class="float-right"
@@ -335,6 +335,22 @@
</q-btn-dropdown>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('visible_wallet_count')"></span>
</div>
<div class="col-8">
<q-input
v-model="user.extra.visible_wallet_count"
:label="$t('visible_wallet_count')"
filled
dense
type="number"
class="q-mb-md"
></q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('color_scheme')"></span>
@@ -356,7 +372,7 @@
flat
@click="themeChoiceFunc('bitcoin')"
icon="circle"
color="orange"
color="deep-orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
@@ -532,6 +548,12 @@
</q-select>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
@@ -38,7 +38,7 @@
: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']"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
+26 -24
View File
@@ -59,9 +59,6 @@
}
}
</style>
<script>
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
</script>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
@@ -87,18 +84,20 @@
>
{{SITE_TITLE}}
</h5>
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
@@ -149,7 +148,7 @@
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
:auth-methods="LNBITS_AUTH_METHODS"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
@@ -180,14 +179,14 @@
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
Aready have an account?
<span v-text="$t('existing_account_question')"></span>
<span
class="text-secondary cursor-pointer"
class="text-secondary cursor-pointer q-ml-sm"
@click="showLogin('username-password')"
>Login</span
>
v-text="$t('login')"
></span>
</p>
</div>
</username-password>
@@ -223,7 +222,7 @@
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
@@ -249,7 +248,7 @@
></q-btn>
</div>
</div>
{% if AD_SPACE %}
{% if AD_SPACE_ENABLED and AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
@@ -279,7 +278,10 @@
</div>
</div>
{% endif %}
<div v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'" class="full-width">
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
+39 -15
View File
@@ -5,7 +5,7 @@
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
%}
<div class="row q-col-gutter-md">
{% if HIDE_API and AD_SPACE %}
{% if HIDE_API and AD_SPACE_ENABLED and AD_SPACE %}
<div class="col-12 col-md-8 q-gutter-y-md">
{% elif HIDE_API %}
<div class="col-12 q-gutter-y-md">
@@ -31,7 +31,7 @@
<q-card-section style="height: 130px">
<div class="row q-gutter-md">
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
v-if="isSatsDenomination"
class="col-1"
style="max-width: 30px"
>
@@ -59,9 +59,11 @@
<div class="col-7">
<div class="row">
<div class="col-auto">
<div class="text-h3 q-my-none text-no-wrap">
<div class="text-h3 q-my-none full-width">
<strong
v-text="walletFormatBalance(this.g.wallet.sat)"
class="text-no-wrap"
:style="{fontSize: 'clamp(0.75rem, 10vw, 3rem)', display: 'inline-block', maxWidth: '100%'}"
></strong>
</div>
</div>
@@ -166,7 +168,12 @@
@click="showReceiveDialog"
:label="$t('create_invoice')"
></q-btn>
<q-btn unelevated color="secondary" icon="qr_code_scanner">
<q-btn
unelevated
color="secondary"
icon="qr_code_scanner"
@click="showCamera"
>
<q-tooltip
><span v-text="$t('camera_tooltip')"></span
></q-tooltip>
@@ -235,6 +242,23 @@
{{ SITE_TITLE }} Wallet:
<strong><em v-text="g.wallet.name"></em></strong>
</div>
<q-space></q-space>
<div class="float-right">
<q-btn
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
round
class="float-right"
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
text-color="black"
size="sm"
icon="push_pin"
style="transform: rotate(30deg)"
>
<q-tooltip
><span v-text="$t('pin_wallet')"></span
></q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
@@ -461,7 +485,7 @@
</q-list>
</q-card-section>
</q-card>
{% endif %} {% if AD_SPACE %}
{% endif %} {% if AD_SPACE_ENABLED and AD_SPACE %}
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
@@ -632,7 +656,7 @@
>
<span
v-if="receive.lnurl"
v-text="$t('withdraw_from') + receive.lnurl.domain"
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
></span>
<span v-else v-text="$t('create_invoice')"></span>
</q-btn>
@@ -726,6 +750,13 @@
</div>
<div v-if="g.fiatTracking">
<div v-if="isFiatPriority">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
<div v-else style="opacity: 0.75">
<div class="text-h5 text-italic">
<span
v-text="parse.invoice.fiatAmount"
@@ -733,13 +764,6 @@
></span>
</div>
</div>
<div v-else style="opacity: 0.75">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
</div>
</div>
<q-separator></q-separator>
@@ -954,8 +978,8 @@
dense
v-model.number="parse.data.amount"
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
:mask="parse.data.unit == 'sat' ? '#' : ''"
:step="parse.data.unit == 'sat' ? '1': '0.01'"
fill-mask="0"
reverse-fill-mask
:min="parse.lnurlpay.minSendable / 1000"
+165
View File
@@ -0,0 +1,165 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}
+50 -8
View File
@@ -232,15 +232,57 @@
/>
</template>
</q-input>
<q-select
<q-btn
v-else-if="['status'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
+3 -2
View File
@@ -61,7 +61,7 @@
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip>Toggle Admin</q-tooltip>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle>
<q-input
@@ -105,11 +105,12 @@
<q-input
v-model="activeUser.data.pubkey"
:label="$t('pubkey')"
:label="'Nostr '+ $t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
+30 -13
View File
@@ -15,19 +15,36 @@
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
class="q-ml-md"
></q-btn>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
+11 -1
View File
@@ -127,7 +127,17 @@
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
+114 -5
View File
@@ -1,31 +1,38 @@
import os
import time
from http import HTTPStatus
from shutil import make_archive
from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen
from typing import Optional
from tempfile import NamedTemporaryFile
from typing import IO, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_notification,
get_balance_delta,
update_cached_settings,
)
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from .. import core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
file_upload = File(...)
@admin_router.get(
@@ -50,6 +57,18 @@ async def api_monitor():
}
@admin_router.get(
"/api/v1/testemail",
name="TestEmail",
description="send a test email to the admin",
dependencies=[Depends(check_admin)],
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
)
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
@@ -94,7 +113,7 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_notification(NotificationType.settings_update, {"username": user.username})
await delete_admin_settings()
await reset_core_settings()
server_restart.set()
@@ -146,3 +165,93 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+12 -6
View File
@@ -130,14 +130,20 @@ async def api_lnurlscan(
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
r = await client.get(url, timeout=5)
r.raise_for_status()
if r.is_error:
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={"domain": domain, "message": "failed to get parameters"},
)
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
+23 -19
View File
@@ -74,7 +74,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed."
)
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
@@ -85,9 +85,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
)
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
@@ -104,7 +102,7 @@ async def nostr_login(request: Request) -> JSONResponse:
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
"Login by 'User ID' not allowed.",
)
account = await get_account(data.usr)
@@ -112,7 +110,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
if account.is_admin:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
HTTPStatus.FORBIDDEN, "Admin users cannot login with user id only."
)
return _auth_success_response(account.username, account.id, account.email)
@@ -242,7 +240,7 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.",
)
@@ -257,7 +255,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.",
)
@@ -285,7 +283,7 @@ async def logout() -> JSONResponse:
async def register(data: RegisterUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
"Register by 'Username and Password' not allowed.",
)
@@ -375,7 +373,7 @@ async def update_password(
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
)
assert data.password == data.password_repeat, "Passwords do not match."
@@ -449,7 +447,7 @@ async def update(
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
@@ -472,10 +470,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
if verified_user_id:
if account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
redirect_path = "/account"
if account:
@@ -499,9 +497,12 @@ def _auth_success_response(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
max_age = settings.auth_token_expire_minutes * 60
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.set_cookie(
"cookie_access_token", access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
response.delete_cookie("is_access_token_expired")
return response
@@ -521,9 +522,12 @@ def _auth_api_token_response(
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
access_token = create_access_token(data=payload.dict())
max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
response.set_cookie(
"cookie_access_token", access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
response.delete_cookie("is_access_token_expired")
return response
@@ -574,10 +578,10 @@ def _find_auth_provider_class(provider: str) -> Callable:
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr Auth header missing.")
scheme, token = auth_header.split()
if scheme.lower() != "nostr":
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
+21 -3
View File
@@ -1,5 +1,4 @@
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlencode, urlparse
@@ -37,7 +36,7 @@ generic_router = APIRouter(
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return FileResponse(Path("lnbits", "static", "favicon.ico"))
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
@@ -217,6 +216,25 @@ async def account(
)
@generic_router.get(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/service-worker.js")
async def service_worker(request: Request):
return template_renderer().TemplateResponse(
@@ -403,7 +421,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_admin)):
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
+4 -1
View File
@@ -212,7 +212,10 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
try:
r.raise_for_status()
return r.json()["noderank"]
data = r.json()
if "noderank" not in data:
return None
return data["noderank"]
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
+35 -23
View File
@@ -44,7 +44,6 @@ from lnbits.core.services.payments import (
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
check_user_exists,
parse_filters,
require_admin_key,
@@ -116,30 +115,44 @@ async def api_payments_history(
@payment_router.get(
"/stats/count",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentCountStat],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
return await get_payment_count_stats(count_by, filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
return await get_wallets_stats(filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_wallets_stats(filters, user_id=for_user_id)
@payment_router.get(
@@ -153,22 +166,13 @@ async def api_payments_daily_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
if not user.admin:
exc = HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Missing wallet id.",
)
wallet_filter = next(
(f for f in filters.filters if f.field == "wallet_id"), None
)
if not wallet_filter:
raise exc
wallet_id = list((wallet_filter.values or {}).values())
if len(wallet_id) == 0:
raise exc
if not user.get_wallet(wallet_id[0]):
raise exc
return await get_payments_daily_stats(filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payments_daily_stats(filters, user_id=for_user_id)
@payment_router.get(
@@ -264,13 +268,21 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
response_description="list of payments",
response_model=Page[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
dependencies=[Depends(check_admin)],
)
async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payments_paginated(
filters=filters,
user_id=for_user_id,
)
@@ -314,7 +326,7 @@ async def api_payments_create(
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
status_code=HTTPStatus.FORBIDDEN,
detail="Invoice (or Admin) key required.",
)
+43 -4
View File
@@ -104,16 +104,24 @@ async def api_create_user(data: CreateUser) -> CreateUser:
)
account.validate_fields()
account.hash_password(data.password)
user = await create_user_account_no_ckeck(account)
user = await create_user_account_no_ckeck(account, default_exts=data.extensions)
data.id = user.id
return data
@users_router.put("/user/{user_id}", name="Update user")
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
async def api_update_user(
user_id: str, data: CreateUser, user: User = Depends(check_admin)
) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if data.password or data.password_repeat:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
@@ -140,7 +148,7 @@ async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id)
wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -249,19 +257,50 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
return SimpleStatus(success=True, message="Wallet is already active.")
@users_router.delete(
"/user/{user_id}/wallets",
name="Delete all wallets for user",
summary="Soft delete (only sets a flag) all user wallets.",
)
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action not allowed.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
return SimpleStatus(
success=True,
message=f"Deleted '{len(wallets)}' wallets. ",
)
@users_router.delete(
"/user/{user_id}/wallet/{wallet}",
name="Delete wallet by id",
summary="First time it is called it does a soft delete (only sets a flag)."
"The second time it is called will delete the entry from the DB",
)
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
async def api_users_delete_user_wallet(
user_id: str, wallet: str, user: User = Depends(check_admin)
) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
+27
View File
@@ -9,13 +9,18 @@ from fastapi import (
HTTPException,
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from ..crud import (
create_wallet,
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
return res
@wallet_router.get(
"/paginated",
name="Wallet List",
summary="get paginated list of user wallets",
response_description="list of user wallets",
response_model=Page[Wallet],
openapi_extra=generate_filter_params_openapi(WalletsFilters),
)
async def api_wallets_paginated(
user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)),
):
page = await get_wallets_paginated(
user_id=user.id,
filters=filters,
)
return page
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@@ -74,6 +99,7 @@ async def api_update_wallet(
icon: Optional[str] = Body(None),
color: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
pinned: Optional[bool] = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
@@ -82,6 +108,7 @@ async def api_update_wallet(
wallet.name = name or wallet.name
wallet.extra.icon = icon or wallet.extra.icon
wallet.extra.color = color or wallet.extra.color
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
+20 -7
View File
@@ -554,9 +554,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters:
where_stmts.append(page_filter.statement)
if self.search and self.model and self.model.__search_fields__:
where_stmts.append(
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
# Use `COALESCE` to handle `NULL` values and `||`
# for cross-database compatible string concatenation
_fields = self.model.__search_fields__
search_expr = " || ".join(
f"COALESCE(CAST({field} AS TEXT), '')" for field in _fields
)
where_stmts.append(f"lower({search_expr}) LIKE :search")
if where_stmts:
return "WHERE " + " AND ".join(where_stmts)
@@ -576,7 +580,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model:
values["search"] = f"%{self.search}%"
values["search"] = f"%{self.search.lower()}%"
return values
@@ -644,9 +648,9 @@ def model_to_dict(model: BaseModel) -> dict:
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null":
if value == "null" or value == "":
return None
_subdict = json.loads(value)
_subdict = _safe_load_json(value)
elif isinstance(value, dict):
_subdict = value
@@ -670,7 +674,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if get_origin(outertype_) is list:
_items = json.loads(value) if isinstance(value, str) else value
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
@@ -691,7 +695,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
_dict[key] = json.loads(value)
_dict[key] = _safe_load_json(value)
continue
_dict[key] = value
continue
@@ -699,3 +703,12 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
def _safe_load_json(value: str) -> dict:
try:
return json.loads(value)
except json.JSONDecodeError:
# DB is corrupted if it gets here
logger.error(f"Failed to decode JSON: '{value}'")
return {}
+16 -8
View File
@@ -5,7 +5,7 @@ import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security import APIKeyHeader, APIKeyQuery, HTTPBearer, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from loguru import logger
from pydantic.types import UUID4
@@ -31,8 +31,15 @@ from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import path_segments
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth",
auto_error=False,
description="OAuth2 access token for authentication with username and password.",
)
http_bearer = HTTPBearer(
auto_error=False,
description="Bearer Token for custom ACL based access control",
)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
@@ -95,7 +102,7 @@ class KeyChecker(SecurityBase):
request.scope["user_id"] = wallet.user
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
status_code=HTTPStatus.FORBIDDEN,
detail="Invalid adminkey.",
)
@@ -132,8 +139,9 @@ async def require_invoice_key(
async def check_access_token(
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
) -> Optional[str]:
return header_access_token or cookie_access_token
return header_access_token or cookie_access_token or bearer_access_token
async def check_user_exists(
@@ -157,7 +165,7 @@ async def check_user_exists(
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
user = await get_user_from_account(account)
if not user:
@@ -193,7 +201,7 @@ async def access_token_payload(
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
)
if not user.has_password:
raise HTTPException(
@@ -206,7 +214,7 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
)
if not user.has_password:
raise HTTPException(
+4 -2
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from shortuuid import uuid
from lnbits.settings import settings
@@ -71,10 +72,11 @@ def register_exception_handlers(app: FastAPI):
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.error(f"Exception: {exc!s}")
exception_id = uuid()
logger.error(f"Exception ID: {exception_id}\n{exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
content={"detail": f"Unexpected error! ID: {exception_id}"},
)
@app.exception_handler(AssertionError)
+69 -62
View File
@@ -10,8 +10,8 @@ from urllib.parse import urlparse
import jinja2
import jwt
import shortuuid
from fastapi import Request
from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
@@ -65,59 +65,52 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
t.env.globals["static_url_for"] = static_url_for
if settings.lnbits_ad_space_enabled:
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
t.env.globals["AD_SPACE_TITLE"] = settings.lnbits_ad_space_title
window_settings = {
"AD_SPACE": settings.lnbits_ad_space.split(","),
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
"HIDE_API": settings.lnbits_hide_api,
"SITE_TITLE": settings.lnbits_site_title,
"SITE_TAGLINE": settings.lnbits_site_tagline,
"SITE_DESCRIPTION": settings.lnbits_site_description,
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and get_node_class() is not None,
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": (
settings.lnbits_denomination
if settings.lnbits_denomination == "FakeWallet"
else "sats"
),
}
t.env.globals["VOIDWALLET"] = settings.lnbits_backend_wallet_class == "VoidWallet"
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
settings.lnbits_show_home_page_elements
)
t.env.globals["LNBITS_CUSTOM_IMAGE"] = settings.lnbits_custom_image
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
t.env.globals["LNBITS_VERSION"] = settings.version
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
settings.lnbits_extensions_deactivate_all
)
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
t.env.globals["LNBITS_NODE_UI"] = (
settings.lnbits_node_ui and get_node_class() is not None
)
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids)
if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.lnbits_default_reaction:
t.env.globals["USE_DEFAULT_REACTION"] = settings.lnbits_default_reaction
if settings.lnbits_default_theme:
t.env.globals["USE_DEFAULT_THEME"] = settings.lnbits_default_theme
if settings.lnbits_default_border:
t.env.globals["USE_DEFAULT_BORDER"] = settings.lnbits_default_border
if settings.lnbits_default_gradient:
t.env.globals["USE_DEFAULT_GRADIENT"] = settings.lnbits_default_gradient
if settings.lnbits_default_bgimage:
t.env.globals["USE_DEFAULT_BGIMAGE"] = settings.lnbits_default_bgimage
t.env.globals["WINDOW_SETTINGS"] = window_settings
for key, value in window_settings.items():
t.env.globals[key] = value
if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
@@ -131,8 +124,6 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
return t
@@ -291,12 +282,21 @@ def is_lnbits_version_ok(
def check_callback_url(url: str):
netloc = urlparse(url).netloc
if not settings.lnbits_callback_url_rules:
# no rules, all urls are allowed
return
u = urlparse(url)
for rule in settings.lnbits_callback_url_rules:
if re.match(rule, netloc) is None:
raise ValueError(
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
)
try:
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
return
except re.error:
logger.debug(f"Invalid regex rule: '{rule}'. ")
continue
raise ValueError(
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
f"Please check your admin settings."
)
def download_url(url, save_path):
@@ -346,5 +346,12 @@ def normalize_path(path: Optional[str]) -> str:
return "/" + "/".join(path_segments(path))
def normalized_path(request: Request) -> str:
return "/" + "/".join(path_segments(request.url.path))
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
image_folder = Path(settings.lnbits_data_folder, directory)
file_path = image_folder / filename
# Prevent dir traversal attack
if image_folder.resolve() not in file_path.resolve().parents:
raise ValueError("Unsafe filename.")
# Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1]
return file_path.resolve()
+88 -70
View File
@@ -4,16 +4,16 @@ import importlib
import importlib.metadata
import inspect
import json
import os
import re
from datetime import datetime, timezone
from enum import Enum
from hashlib import sha256
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
@@ -260,7 +260,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
@@ -271,13 +271,29 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
lnbits_upload_allowed_types: list[str] = Field(
default=[
"image/png",
"image/jpeg",
"image/jpg",
"image/heic",
"image/heif",
"image/heics",
"png",
"jpeg",
"jpg",
"heic",
"heif",
"heics",
]
)
class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000)
lnbits_reserve_fee_percent: float = Field(default=1.0)
lnbits_service_fee: float = Field(default=0)
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
lnbits_reserve_fee_percent: float = Field(default=1.0, ge=0)
lnbits_service_fee: float = Field(default=0, ge=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: str | None = Field(default=None)
@@ -293,9 +309,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=30)
lnbits_exchange_history_size: int = Field(default=60)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300)
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -360,24 +376,24 @@ class ExchangeProvidersSettings(LNbitsSettings):
class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_no: int = Field(default=200, ge=0)
lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[])
lnbits_callback_url_rules: list[str] = Field(
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
)
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_wallet_limit_max_balance: int = Field(default=0, ge=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0, ge=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0, ge=0)
lnbits_only_allow_incoming_payments: bool = Field(default=False)
lnbits_watchdog_switch_to_voidwallet: bool = Field(default=False)
lnbits_watchdog_interval_minutes: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000)
lnbits_watchdog_interval_minutes: int = Field(default=60, gt=0)
lnbits_watchdog_delta: int = Field(default=1_000_000, gt=0)
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000)
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
def is_wallet_max_balance_exceeded(self, amount):
return (
@@ -396,6 +412,7 @@ class NotificationsSettings(LNbitsSettings):
lnbits_telegram_notifications_chat_id: str = Field(default="")
lnbits_email_notifications_enabled: bool = Field(default=False)
lnbits_email_notifications_email: str = Field(default="")
lnbits_email_notifications_username: str = Field(default="")
lnbits_email_notifications_password: str = Field(default="")
lnbits_email_notifications_server: str = Field(default="smtp.protonmail.ch")
lnbits_email_notifications_port: int = Field(default=587)
@@ -406,13 +423,18 @@ class NotificationsSettings(LNbitsSettings):
notification_balance_delta_changed: bool = Field(default=True)
lnbits_notification_server_start_stop: bool = Field(default=True)
lnbits_notification_watchdog: bool = Field(default=False)
lnbits_notification_server_status_hours: int = Field(default=24)
lnbits_notification_incoming_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_outgoing_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
lnbits_notification_incoming_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
lnbits_notification_outgoing_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
lnbits_denomination: str = Field(default="sats")
class LNbitsFundingSource(LNbitsSettings):
@@ -531,11 +553,20 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
)
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600)
lightning_invoice_expiry: int = Field(default=3600, gt=0)
class FundingSourcesSettings(
@@ -558,11 +589,12 @@ class FundingSourcesSettings(
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
StrikeFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status
# It will not fail the payment, it will make it return pending after the timeout
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5)
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
class WebPushSettings(LNbitsSettings):
@@ -601,7 +633,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
@@ -611,7 +643,7 @@ class AuthSettings(LNbitsSettings):
)
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120)
auth_credetials_update_threshold: int = Field(default=120, gt=0)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
@@ -637,13 +669,15 @@ class KeycloakAuthSettings(LNbitsSettings):
keycloak_discovery_url: str = Field(default="")
keycloak_client_id: str = Field(default="")
keycloak_client_secret: str = Field(default="")
keycloak_client_custom_org: str | None = Field(default=None)
keycloak_client_custom_icon: str | None = Field(default=None)
class AuditSettings(LNbitsSettings):
lnbits_audit_enabled: bool = Field(default=True)
# number of days to keep the audit entry
lnbits_audit_retention_days: int = Field(default=7)
lnbits_audit_retention_days: int = Field(default=7, ge=0)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
@@ -780,7 +814,7 @@ class EnvSettings(LNbitsSettings):
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000)
port: int = Field(default=5000, gt=0)
forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
@@ -792,24 +826,27 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time())
cleanup_wallets_days: int = Field(default=90)
funding_source_max_retries: int = Field(default=4)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: str | None = Field(default=None)
lnbits_saas_secret: str | None = Field(default=None)
lnbits_saas_instance_id: str | None = Field(default=None)
def check_auth_secret_key(self):
if self.auth_secret_key:
return
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
auth_key_file = Path(settings.lnbits_data_folder, ".lnbits_auth_key")
if auth_key_file.is_file():
with open(auth_key_file) as file:
self.auth_secret_key = file.readline()
return
self.auth_secret_key = uuid4().hex
with open(auth_key_file, "w+") as file:
file.write(self.auth_secret_key)
class PersistenceSettings(LNbitsSettings):
@@ -838,6 +875,7 @@ class SuperUserSettings(LNbitsSettings):
"VoidWallet",
"ZBDWallet",
"NWCWallet",
"StrikeWallet",
]
)
@@ -861,6 +899,13 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_all_extensions_ids: set[str] = Field(default=[])
server_startup_time: int = Field(default=time())
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
@@ -869,7 +914,6 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
class ReadOnlySettings(
EnvSettings,
ExtensionsInstallSettings,
SaaSSettings,
PersistenceSettings,
SuperUserSettings,
):
@@ -948,31 +992,6 @@ def set_cli_settings(**kwargs):
setattr(settings, key, value)
def send_admin_user_to_saas():
if settings.lnbits_saas_callback:
with httpx.Client() as client:
headers = {
"Content-Type": "application/json; charset=utf-8",
"X-API-KEY": settings.lnbits_saas_secret,
}
payload = {
"instance_id": settings.lnbits_saas_instance_id,
"adminuser": settings.super_user,
}
try:
client.post(
settings.lnbits_saas_callback,
headers=headers,
json=payload,
)
logger.success("sent super_user to saas application")
except Exception as e:
logger.error(
"error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {e!s}"
)
readonly_variables = ReadOnlySettings.readonly_fields()
transient_variables = TransientSettings.readonly_fields()
@@ -981,9 +1000,8 @@ settings = Settings()
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
settings.version = importlib.metadata.version("lnbits")
settings.auth_secret_key = (
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
)
settings.check_auth_secret_key()
if not settings.user_agent:
settings.user_agent = f"LNbits/{settings.version}"
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7 -5
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -110,16 +110,16 @@ body[data-theme=bitcoin].body--light {
}
[data-theme=bitcoin] .bg-primary {
background: #ff9853 !important;
background: #ea611d !important;
}
[data-theme=bitcoin] .text-primary {
color: #ff9853 !important;
color: #ea611d !important;
}
[data-theme=bitcoin] .bg-secondary {
background: #ff7353 !important;
background: #e56f35 !important;
}
[data-theme=bitcoin] .text-secondary {
color: #ff7353 !important;
color: #e56f35 !important;
}
[data-theme=bitcoin] .bg-dark {
background: #2d293b !important;
@@ -134,10 +134,10 @@ body[data-theme=bitcoin].body--light {
color: #333646 !important;
}
[data-theme=bitcoin] .bg-marginal-bg {
background: #2d293b !important;
background: #000000 !important;
}
[data-theme=bitcoin] .text-marginal-bg {
color: #2d293b !important;
color: #000000 !important;
}
[data-theme=bitcoin] .bg-marginal-text {
background: #fff !important;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

+43 -16
View File
@@ -27,6 +27,7 @@ window.localisation.en = {
close_channel: 'Close Channel',
close: 'Close',
restart: 'Restart server',
image_library: 'Image Library',
save: 'Save',
save_tooltip: 'Save your changes',
credit_debit: 'Credit / Debit',
@@ -48,8 +49,11 @@ window.localisation.en = {
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',
add_wallet: 'Add a new wallet',
add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
delete_wallet: 'Delete wallet',
delete_wallet_desc:
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
@@ -64,9 +68,9 @@ window.localisation.en = {
donate: 'Donate',
view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled',
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_badge: 'Service fee: {amount} % per transaction',
service_fee_max_badge:
'Service fee: {amount} % per transaction (max {max} {denom})',
service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode',
@@ -116,7 +120,7 @@ window.localisation.en = {
copy_wallet_url: 'Copy wallet URL',
disclaimer_dialog_title: 'Important!',
disclaimer_dialog:
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nLNbits holds no responsibility for loss of access to funds.',
no_transactions: 'No transactions made yet',
manage: 'Manage',
exchanges: 'Exchanges',
@@ -124,6 +128,7 @@ window.localisation.en = {
no_extensions: "You don't have any extensions installed :(",
created: 'Created',
search_extensions: 'Search extensions',
search_wallets: 'Search wallets',
extension_sources: 'Extension Sources',
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
ext_sources_label:
@@ -175,6 +180,7 @@ window.localisation.en = {
payment_proof: 'Payment Proof',
update: 'Update',
update_available: 'Update {version} available!',
funding_sources: 'Funding Sources',
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
@@ -198,9 +204,13 @@ window.localisation.en = {
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notfications over Email',
notifications_enable_email_desc: 'Send notifications over email',
notifications_send_test_email: 'Send test email',
notifications_send_email: 'Send email',
notifications_send_email_desc: 'Email you will send from',
notifications_send_email_username: 'Username',
notifications_send_email_username_desc:
'Username, will use the email if not set',
notifications_send_email_password: 'Send email password',
notifications_send_email_password_desc:
'Password for the email you will send from',
@@ -258,6 +268,7 @@ window.localisation.en = {
notification_source_label:
'Source URL (only use the official LNbits status source, and sources you can trust)',
more: 'more',
more_count: '{count} more',
less: 'less',
releases: 'Releases',
watchdog: 'Watchdog',
@@ -272,7 +283,7 @@ window.localisation.en = {
callback_url_rules: 'Callback URL Rules',
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
callback_url_rule_hint:
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
'Callback URLs (like LNURL one) will be validated against these rules. At leat one rule must match. No rule means all URLs are allowed.',
wallet_limiter: 'Wallet Limiter',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
@@ -297,6 +308,9 @@ window.localisation.en = {
login_with_user_id: 'Login with user ID',
or: 'or',
create_new_wallet: 'Create New Wallet',
delete_all_wallets: 'Delete All Wallets',
confirm_delete_all_wallets:
'Are you sure you want to delete ALL wallets for this user?',
login_to_account: 'Login to your account',
create_account: 'Create account',
account_settings: 'Account Settings',
@@ -305,21 +319,28 @@ window.localisation.en = {
signin_with_nostr: 'Continue with Nostr',
signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak',
signin_with_custom_org: 'Sign in with {custom_org}',
username_or_email: 'Username or Email',
password: 'Password',
password_config: 'Password Config',
password_repeat: 'Password repeat',
update_password: 'Update Password',
change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
nostr_pubkey_tooltip: "Enter this user's Nostr public key (hex value)",
set_password: 'Set Password',
set_password_tooltip: 'Set a password for this user',
invalid_password: 'Password must have at least 8 characters',
invalid_password_repeat: 'Passwords do not match',
reset_key_generated: 'A reset key has been generated.',
reset_key_copy: 'Click OK to copy the reset URL to your clipboard.',
login: 'Login',
register: 'Register',
username: 'Username',
pubkey: 'Public Key',
user_id: 'User ID',
id: 'ID',
email: 'Email',
first_name: 'First Name',
last_name: 'Last Name',
@@ -330,6 +351,7 @@ window.localisation.en = {
invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider',
my_account: 'My Account',
existing_account_question: 'Already have an account?',
background_image: 'Background Image',
back: 'Back',
logout: 'Logout',
@@ -345,6 +367,7 @@ window.localisation.en = {
gradient_background: 'Gradient Background',
language: 'Language',
color_scheme: 'Color Scheme',
visible_wallet_count: 'Visible Wallet Count',
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
@@ -435,7 +458,7 @@ window.localisation.en = {
fee_reserve_percent: 'Fee Reserve Percent',
fee_reserve_msats: 'Reserve fee in msats',
reserve_fee_in_percent: 'Reserve fee in percent',
payment_wait_time: 'Payment Wait Time',
payment_wait_time: 'Payment Wait Time (sec)',
payment_wait_time_desc:
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
server_management: 'Server Management',
@@ -461,6 +484,8 @@ window.localisation.en = {
auth_keycloak_ci_hint:
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak Client Secret',
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
currency_settings: 'Currency Settings',
allowed_currencies: 'Allowed Currencies',
allowed_currencies_hint: 'Limit the number of available fiat currencies',
@@ -488,9 +513,11 @@ window.localisation.en = {
'Disable Service Fee for Internal Lightning Payments',
ui_management: 'UI Management',
ui_site_title: 'Site Title',
ui_changing_remove_lnbits_elements:
' (changing will remove LNbits elements on the homepage and footer)',
ui_site_tagline: 'Site Tagline',
ui_elements_enable: 'Enable elements on homepage',
ui_elements_disable: 'Disable elements on homepage',
ui_elements_enable: 'Enable elements on homepage/footer',
ui_elements_disable: 'Disable elements on homepage/footer',
ui_toggle_elements_tip: "Remove homepage elements like 'runs on' etc",
ui_site_description: 'Site Description',
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
@@ -500,14 +527,13 @@ window.localisation.en = {
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code Logo',
ui_qr_code_logo_hint: 'URL to logo image in QR code',
ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: 'Image showed at homepage/login',
ui_custom_badge: 'Custom Badge',
ui_custom_badge_label:
"Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'",
ui_custom_badge_label: "Custom Badge 'USE WITH CAUTION'",
ui_custom_badge_color_label: 'Custom Badge Color',
themes: 'Themes',
themes_hint: 'Choose themes available for users',
@@ -560,5 +586,6 @@ window.localisation.en = {
view_list: 'View wallets as list',
view_column: 'View wallets as rows',
filter_payments: 'Filter payments',
filter_date: 'Filter by date'
filter_date: 'Filter by date',
websocket_example: 'Websocket example'
}
+98 -57
View File
@@ -5,8 +5,8 @@ window.localisation.fi = {
site_customisation: 'Sivuston kustomointi',
funding: 'Rahoitus',
users: 'Käyttäjät',
audit: 'Auditointi',
api_watch: 'Api Watch',
audit: 'Seuranta',
api_watch: 'API-seuranta',
apps: 'Sovellukset',
channels: 'Kanavat',
transactions: 'Tapahtumat',
@@ -27,6 +27,7 @@ window.localisation.fi = {
close_channel: 'Sulje kanava',
close: 'Sulje',
restart: 'Palvelimen uudelleen käynnistys',
image_library: 'Kuvakirjasto',
save: 'Tallenna',
save_tooltip: 'Tallenna muutokset',
credit_debit: 'Hyvitä / Veloita',
@@ -37,7 +38,7 @@ window.localisation.fi = {
'Virtuaalivarojen ({amount} sat) hyvitys-/veloitustapahtuma onnistui. Maksukyky riippuuu rahoituslähteen todellisista varoista.',
restart_tooltip: 'Uudelleenkäynnistä palvelu muutosten käyttöönottamiseksi',
add_funds_tooltip: 'Lisää varoja lompakkoon',
reset_defaults: 'Peruuta muutokset',
reset_defaults: 'Palauta oletusasetukset',
reset_defaults_tooltip:
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio',
@@ -65,8 +66,7 @@ window.localisation.fi = {
'Näyttää että sinulla on lunastamattomia bitcoin varoja, mutta sinulla ei vielä ole lompakkoa. Lunasta varat allaolevaa nappia painamalla, ja sinulle luodaan lompakko.',
donate: 'Lahjoita',
view_github: 'Näytä GitHub:ssa',
voidwallet_active:
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
voidwallet_active: 'VoidWallet on aktiivinen. Se ei tue maksutapahtumia!',
use_with_caution:
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: {name}',
service_fee_tooltip:
@@ -84,6 +84,7 @@ window.localisation.fi = {
create_invoice: 'Laskuta',
camera_tooltip: 'Kuvaa lasku tai QR-koodi',
export_csv: 'Vie CSV-tiedostoon',
export_csv_details: 'Vie CSV-tiedostoon lisätietoineen',
chart_tooltip: 'Näytä kaaviokuva',
pending: 'Odottaa',
copy_invoice: 'Kopioi lasku',
@@ -99,7 +100,7 @@ window.localisation.fi = {
payment_processing: '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',
invoice_waiting: 'Lasku odottaa maksua',
payment_received: 'Maksu vastaanotettu',
payment_sent: 'Maksu lähetetty',
payment_failed: 'Maksu epäonnistui',
@@ -113,10 +114,10 @@ window.localisation.fi = {
copy_wallet_url: 'Kopioi lompakon URL',
disclaimer_dialog_title: 'Tärkeää!',
disclaimer_dialog:
'Sinun *PITÄÄ TALLETTAA* kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossa kirjautumaan lompakkoosi! Löydät kirjautumistiedot Tilin asetukset -sivulta. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
'Sinun *PITÄÄ TALLETTAA* kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossa kirjautumaan lompakkoosi! Löydät kirjautumistiedot Tilin asetukset -sivulta. 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',
exchanges: 'rssit',
exchanges: 'Vaihtokurssit',
extensions: 'Laajennukset',
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
@@ -124,7 +125,7 @@ window.localisation.fi = {
extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label:
'Lähde-URL (käytä vain virallista LNbits ja luotettavia laajennuslähteitä)',
'Lähde-URL (käytä vain virallista LNbits tai muuta luotettaa laajennuslähdettä)',
warning: 'Varoitus',
repository: 'Laajennuksien lähde',
confirm_continue: 'Haluatko varmasti jatkaa?',
@@ -168,11 +169,12 @@ window.localisation.fi = {
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanheneminen',
expiry: 'Vanhenee',
webhook: 'Webhook',
payment_proof: 'Maksun varmenne',
update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!',
update_available: 'Rahoituslähteet',
latest_update:
'Käytössä oleva versio {version}, on viimeisin saatavilla oleva.',
notifications: 'Tiedotteet',
@@ -182,7 +184,7 @@ window.localisation.fi = {
notifications_enable_nostr_desc: 'Lähetä tietodukset Nostr:in kautta',
notifications_nostr_private_key: 'Nostr-yksityisavain',
notifications_nostr_private_key_desc:
'Yksityinen avain (hex tai nsec) Nostr viestien lähettämisen allekirjoitukseen',
'Yksityinen avain (hex tai nsec) Nostr-viestien lähettämisen allekirjoitukseen',
notifications_nostr_identifiers: 'Nostr-tunnisteet',
notifications_nostr_identifiers_desc:
'Lista tunnisteista kenelle tiedotukset lähetetään',
@@ -195,6 +197,25 @@ window.localisation.fi = {
notifications_chat_id: 'Keskustelun tunnus',
notifications_chat_id_desc: 'Keskustelun tunnus minne tiedotukset lähetetään',
notifications_email_config: 'Sähköposti määritykset',
notifications_enable_email: 'Käytä sähköpostia',
notifications_enable_email_desc: 'Lähetä tiedotteet sähköpostilla',
notifications_send_test_email: 'Lähetä testiposti',
notifications_send_email: 'Lähetä sähköpostiosoitteella',
notifications_send_email_desc: 'Lähettäjänä näkyvä sähköpostiosoite',
notifications_send_email_username: 'Käyttäjätunnus',
notifications_send_email_username_desc:
'Käyttäjätunnus, mikäli tyhjä, käytetään sähköpostiosoitetta',
notifications_send_email_password: 'Lähtevän sähköpostin salasana',
notifications_send_email_password_desc: 'Salasana lähettävälle sähköpostille',
notifications_send_email_server_port: 'Lähtevän sähköpostin SMTP-portti',
notifications_send_email_server_port_desc: 'SMTP-palvelimen portti',
notifications_send_email_server: 'Lähtevän sähköpostin SMTP-palvelin',
notifications_send_email_server_desc:
'SMTP-palvelin jonka kautta sähköpostit lähetetään',
notifications_send_to_emails: 'Sähköpostien vastaanottaja',
notifications_send_to_emails_desc: 'Kenelle sähköpostit lähetetään',
notification_settings_update: 'Asetuksia päivitetty',
notification_settings_update_desc:
'Tiedota kun palvelimen asetuksia on päivitetty',
@@ -213,15 +234,15 @@ window.localisation.fi = {
notification_incoming_payment: 'Saapuvat maksut',
notification_incoming_payment_desc:
'Tiedota kun lompakko vastaanottaa ja saapuvan maksun määrä ylittää rajan (sat)',
'Tiedota kun lompakon vastaanottaman ja saapuvan maksun määrä ylittää rajan (sat)',
notification_outgoing_payment: 'Lähtevät maksut',
notification_outgoing_payment_desc:
'Tiedota kun lompakko lähettää ja lähtevän maksun määrä ylittää rajan (sat)',
'Tiedota kun lompakon lähettävän ja maksettavan maksun määrä ylittää rajan (sat)',
notification_credit_debit: 'Hyvitä / Veloita',
notification_credit_debit: 'Hyvitys / Veloitus',
notification_credit_debit_desc:
'Tiedota kun lompakkoa Superuser hyvittää tai veloittaa lompakkoa',
'Tiedota kun Superuser tekee lompakon hyvitys- tai veloitustapahtumia',
notification_balance_delta_changed: 'Saldon määrän muutos',
notification_balance_delta_changed_desc:
@@ -235,48 +256,48 @@ window.localisation.fi = {
'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:
'Mikäli rahoituslähteen saldo laskee alle LNbits kokonaissaldon, muutetaan Rahoituslähteeksi muutetaan heti VoidWallet. Päivittämisen jälkeen asetus pitää päivittää manuaalisestsi.',
'Mikäli rahoituslähteen saldo laskee alle LNbits kokonaissaldon, muutetaan rahoituslähteeksi heti VoidWallet. Päivittämisen jälkeen asetus pitää päivittää manuaalisestsi.',
status: 'Tilanne',
notification_source: 'Tiedotteiden lähde',
notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'laajenna',
more: 'näytä lisää',
less: 'supista',
releases: 'Julkaisut',
watchdog: 'Watchdog',
server_logs: 'Palvelimen lokit',
ip_blocker: 'IP-suodatin',
ip_blocker: 'Palvelimen suojaus IP-osoitesuodattimella',
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',
callback_url_rules: 'Callback URL säännöt',
enter_callback_url_rule: 'Anna URL säätö regex-muodossa ja paina enter',
callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint:
'Callback URL:it (kuten LNURL) tarkistetaan näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
wallet_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
wallet_limit_max_withdraw_per_day:
'Päivittäinen lompakosta nostettavissa oleva määrä satosheissa (0 poistaa käytöstä)',
wallet_max_ballance: 'Lompakon maksimisaldo satosheina (0 poistaa käytöstä)',
'Päivittäin nostettavissa sat maksimi (0 poistaa käytöstä)',
wallet_max_ballance: 'Maksimisaldo (sat) (0 poistaa käytöstä)',
wallet_limit_secs_between_trans:
'Min sekuntia transaktioiden välillä lompakkoa kohden (0 poistaa käytöstä)',
only_incoming_payments_allowed: 'Only incoming payments allowed',
disable_outgoing_payments: 'Disable outgoing payments',
'Tapahtumien välinen minimi (sec) (0 poistaa käytöstä)',
only_incoming_payments_allowed: 'Vain saapuvat maksut sallittuna',
disable_outgoing_payments: 'Poista lähtevät maksut käytöstä',
number_of_requests: 'Pyyntöjen lukumäärä',
time_unit: 'aikayksikkö',
minute: 'minuutti',
settings: 'Settings',
settings: 'Asetukset',
second: 'sekunti',
hour: 'tunti',
disable_server_log: 'Poista palvelimen loki käytöstä',
enable_server_log: 'Ota palvelimen loki käyttöön',
disable_server_log: 'Piilota palvelimen loki',
enable_server_log: 'Näytä palvelimen loki',
coming_soon: 'Ominaisuus on tulossa pian',
session_has_expired: 'Käyttämätön sessio on vanhentunut. Kirjaudu uudelleen.',
instant_access_question: 'tai kirjaudu suoraan',
instant_access_question: 'perinteinen kirjautuminen',
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
@@ -293,11 +314,15 @@ window.localisation.fi = {
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
password_repeat: 'Toista uusi salasana',
update_password: 'Päivitä salasana',
change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain',
set_password: 'Aseta salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
invalid_password_repeat: 'Salasanat eivät täsmää',
reset_key_generated: 'Salasanan vaihtoavain on luotu.',
reset_key_copy: 'Kopioi vaihto-URL leikepöydälle painamalla OK.',
login: 'Kirjaudu',
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
@@ -312,7 +337,8 @@ window.localisation.fi = {
update_account: 'Päivitä tiliä',
invalid_username: 'Virheellinen käyttäjänimi',
auth_provider: 'Tunnistamisen toimittaja',
my_account: 'Minun tili',
my_account: 'Tilini',
existing_account_question: 'Onkohan sinulla jo tili?',
background_image: 'Taustakuva',
back: 'Takaisin',
logout: 'Poistu',
@@ -358,7 +384,7 @@ window.localisation.fi = {
welcome_lnbits: 'Tervetuloa LNbits-palveluun',
setup_su_account: 'Määritä Superuser-tili alta.',
create_ticker_converter: 'Luo valuuttamuuntimen Ticker',
enable_audit: 'Ota auditointi käyttöön',
enable_audit: 'Ota seuranta käyttöön',
recommended: 'Suositeltu',
audit_desc: 'Tallenna HTTP-pyyntöjä seuraavien suodattimien mukaisesti',
audit_record_req: 'Tallenna pyynnön Body',
@@ -366,16 +392,16 @@ window.localisation.fi = {
audit_record_req_warning_1:
'Luottamukselliset tiedot (kuten salasanat) tallennetaan.',
audit_record_req_warning_2: 'Body-datamäätä voi olla iso.',
audit_record_use: 'Käytä sitä varoen.',
audit_record_use: 'Käytä varoen!',
audit_ip: 'Tallenna IP-osoite',
audit_ip_desc: 'Tallenna asiakkaan IP-osoite',
audit_path_params: 'Tallenna Path-parametrit',
audit_query_params: 'Tallenna Query-parametrit',
audit_http_methods: 'Sisällytä HTTP-menetelmät',
audit_http_methods: 'Tallenna HTTP-menetelmät',
audit_http_methods_hint:
'Luettelo mukaan otettavista HTTP-menetelmistä. Tyhjä luettelo tallettaa kaikki.',
audit_http_methods_label: 'HTTP-metodit',
audit_resp_codes: 'Sisällytä HTTP-vastauskoodit',
audit_resp_codes: 'Tallenna HTTP-vastauskoodit',
audit_resp_codes_hint:
'HTTP-koodien lista, jotka sisällytetään (regex-match). Tyhjä luettelo tallettaa kaikki. Esim: 4.*, 5.*',
audit_resp_codes_label: 'HTTP-vastauskoodi (säännöllinen lauseke)',
@@ -410,7 +436,7 @@ window.localisation.fi = {
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
node_managment: 'Solmun hallinta',
node_management_not_supported:
'Valittu rahoituslähde ei mahdollista solmun hallintaan.',
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
toggle_node_ui: 'Solmun käyttöliittymä',
toggle_public_node_ui: 'Julkinen näkymä solmun tietoihin',
toggle_transactions_node_ui:
@@ -418,17 +444,21 @@ window.localisation.fi = {
invoice_expiry: 'Laskun vanhenemisaika',
invoice_expiry_label: 'Laskun vanhentuminen (sekunteina)',
fee_reserve: 'Kuluvaraus',
fee_reserve_msats: 'Kuluvaraus milli-sat',
fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika',
payment_wait_time_desc:
'Kuinka pitkään maksua odotetaan saapuvaksi ennenkuin se merkitään Odotetaan -tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua tms',
server_management: 'Palvelimen hallinta',
base_url: 'Perus-URL',
base_url_label: 'Staattinen/pohjan URL palvelimelle',
authentication: 'Todennus',
auth_token_expiry_label: 'Tunnuksen vanhentumisaika minuutteina',
auth_token_expiry_hint: 'Aika minuuteissa, kunnes tunnus vanhenee',
base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL',
authentication: 'Käyttäjän todennus',
auth_token_expiry_label: 'Kirjautumisen vanhentumisaika minuutteina',
auth_token_expiry_hint: 'Aika minuuteissa, jossa kirjautuminen vanhenee',
auth_allowed_methods_label: 'Sallitut kirjautumismenetelmät',
auth_allowed_methods_hint: 'Valitse kirjautumismenetelmät',
auth_nostr_label: 'Nostr Request URL',
auth_nostr_label: 'Nostr kutsujen URL',
auth_nostr_hint:
'Asiakkaiden kirjautumiseen käyttämä absoluuttinen URL-osoite.',
auth_google_ci_label: 'Google-asiakastunnus',
@@ -446,27 +476,33 @@ window.localisation.fi = {
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint:
'Rajoita käytettävissä olevien fiat-valuuttojen määrää',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
service_fee: 'Palvelumaksu',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa',
max_outgoing_payment_amount: 'Lähtevän maksun enimmäismäärä',
max_outgoing_payment_amount_desc: 'Enimmäismäärä jonka voi maksaa',
service_fee: 'Palvelumaksut',
service_fee_label: 'Palvelumaksu (%)',
service_fee_hint: 'Tapahtumastakohtainen palvelumaksu (%)',
service_fee_max: 'Palvelumaksun enimmäismäärä',
service_fee_max_label: 'Palvelumaksu enintään (sat)',
service_fee_max_label: 'Palvelumaksu max (sat)',
service_fee_max_hint: 'Suurin veloitettava palvelumaksu (sat)',
fee_wallet: 'Palvelumaksujen lompakko',
fee_wallet_label: 'Palvelumaksujen lompakko (lompakon tunnus)',
fee_wallet_hint: 'Lompakon tunnus, johon lähetetään maksut lähtetetään',
fee_wallet_label: 'Palvelumaksujen tilityslompakko (lompakon tunnus)',
fee_wallet_hint: 'Lompakon tunnus, johon palvelumaksut tilitetään',
disable_fee: 'Poista maksu käytöstä',
disable_fee_internal: 'Poista palvelumaksu sisäisiltä maksuilta',
disable_fee_internal_desc: 'Poista palvelumaksu sisäisiltä salamamaksuilta',
disable_fee_internal_desc: 'Poista palvelumaksu sisäisiltä salamaksuilta',
ui_management: 'Käyttöliittymän hallinta',
ui_site_title: 'Sivuston nimi',
ui_changing_remove_lnbits_elements:
' (tämän muuttamalla LNbits elementit poistuvat kotisivulla ja alareunasta)',
ui_site_tagline: 'Sivuston iskulause',
ui_elements_enable: 'Ota käyttöön elementit etusivulla',
ui_elements_disable: 'Poista elementit käytöstä etusivulla',
ui_elements_enable: 'Ota käyttöön elementit etusivulla/alareunassa',
ui_elements_disable: 'Poista elementit käytöstä etusivulla/alareunassa',
ui_toggle_elements_tip: "Poista kotisivuelementit kuten 'toimii' jne.",
ui_site_description: 'Sivuston kuvaus',
ui_site_description_hint:
@@ -476,10 +512,11 @@ window.localisation.fi = {
lnbits_wallet: 'LNbits-lompakko',
denomination: 'Valuutan nimi',
denomination_hint: 'FakeWallet-lompakon valuutan nimi',
ui_qr_code_logo: 'QR-koodin logo',
denomination_error: 'Valuutta tunnisssa on oltava 3 merkkiä, tai `sat`',
ui_qr_code_logo: 'QR- ja Favicon-logo',
ui_qr_code_logo_hint:
'Anna QR-koodissa käytettävää logo-kuvaan osoittava URL',
ui_custom_image: 'Yksilöyty kuva',
'Anna QR-koodissa ja Faviconissa käytettävän logo-kuvan URL',
ui_custom_image: 'Yksilöity kuva',
ui_custom_image_label: 'Anna yksilöidyn kuvan URL-osoite',
ui_custom_image_hint:
'Yksilöity kuva näytetään aloitus- ja kirjautumissivuilla',
@@ -507,7 +544,8 @@ window.localisation.fi = {
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Salli uusien käyttäjien luominen etusivulla',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
new_user_not_allowed: 'Tunnusten luonti on estetty.',
components: 'Komponentit',
long_running_endpoints: 'Top 5 pisimpään yhteydessä ollutta päätepistettä',
http_request_methods: 'HTTP-pyynnön menetelmät',
@@ -535,6 +573,9 @@ window.localisation.fi = {
reset_wallet_keys: 'Uusi API-avaimet',
reset_wallet_keys_desc:
'Tämän lompakon API-avaimet uusitaan. Edelliset API-avaimet lakkaavat toimimasta ja uudet luodaan niiden tilalle..',
view_list: 'Näytä lompakot listana',
view_column: 'Näytä lompakot riveinä'
view_list: 'Näytä lompakot allekain',
view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket esimerkki'
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+88 -5
View File
@@ -4,6 +4,7 @@ window.AdminPageLogic = {
return {
settings: {},
logs: [],
library_images: [],
serverlogEnabled: false,
lnbits_theme_options: [
'classic',
@@ -140,6 +141,7 @@ window.AdminPageLogic = {
async created() {
await this.getSettings()
await this.getAudit()
await this.getUploadedImages()
this.balance = +'{{ balance|safe }}'
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
@@ -438,7 +440,7 @@ window.AdminPageLogic = {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
.then(response => {
Quasar.Notify.create({
this.$q.notify({
type: 'positive',
message: 'Success! Restarted Server',
icon: null
@@ -448,9 +450,31 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError)
},
formatDate(date) {
return moment(date * 1000).fromNow()
return moment.utc(date * 1000).fromNow()
},
sendTestEmail() {
LNbits.api
.request(
'GET',
'/admin/api/v1/testemail',
this.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
this.$q.notify({
message: 'Test email sent!',
color: 'positive'
})
})
.catch(error => {
this.$q.notify({
message: error.message,
color: 'negative'
})
})
},
getAudit() {
LNbits.api
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
@@ -522,10 +546,69 @@ window.AdminPageLogic = {
Quasar.Notify.create({
type: 'positive',
message:
'Success! Restored settings to defaults, restart required!',
'Success! Restored settings to defaults. Restarting...',
icon: null
})
this.needsRestart = true
this.$q.localStorage.clear()
})
.catch(LNbits.utils.notifyApiError)
})
},
onImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadImage(file)
}
},
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
LNbits.api
.request(
'POST',
'/admin/api/v1/images',
this.g.user.wallets[0].adminkey,
formData,
{headers: {'Content-Type': 'multipart/form-data'}}
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image uploaded!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
},
getUploadedImages() {
LNbits.api
.request('GET', '/admin/api/v1/images', this.g.user.wallets[0].inkey)
.then(response => {
this.library_images = response.data.map(image => ({
...image,
url: `${window.origin}/${image.directory}/${image.filename}`
}))
})
.catch(LNbits.utils.notifyApiError)
},
deleteImage(filename) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this image?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
`/admin/api/v1/images/${filename}`,
this.g.user.wallets[0].adminkey
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image deleted!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
})
+28 -13
View File
@@ -197,7 +197,8 @@ window.LNbits = {
email: data.email,
extensions: data.extensions,
wallets: data.wallets,
super_user: data.super_user
super_user: data.super_user,
extra: data.extra ?? {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets
@@ -205,14 +206,21 @@ window.LNbits = {
return mapWallet(obj)
})
.sort((a, b) => {
if (a.extra.pinned !== b.extra.pinned) {
return a.extra.pinned ? -1 : 1
}
return a.name.localeCompare(b.name)
})
obj.walletOptions = obj.wallets.map(obj => {
return {
label: [obj.name, ' - ', obj.id].join(''),
label: [obj.name, ' - ', obj.id.substring(0, 5), '...'].join(''),
value: obj.id
}
})
obj.hiddenWalletsCount = Math.max(
0,
data.wallets.length - data.extra.visible_wallet_count
)
return obj
},
wallet(data) {
@@ -252,13 +260,11 @@ window.LNbits = {
fiat_currency: data.fiat_currency
}
obj.date = Quasar.date.formatDate(new Date(obj.time), window.dateFormat)
obj.dateFrom = moment(obj.date).fromNow()
obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry),
window.dateFormat
)
obj.expirydateFrom = moment(obj.expirydate).fromNow()
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
@@ -495,8 +501,10 @@ window.windowMixin = {
? this.$q.localStorage.getItem('lnbits.backgroundImage')
: USE_DEFAULT_BGIMAGE,
isUserAuthorized: false,
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
walletEventListeners: [],
backgroundImage: ''
backgroundImage: '',
...WINDOW_SETTINGS
}
},
@@ -508,6 +516,11 @@ window.windowMixin = {
}
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
},
goToWallets() {
this.$router.push({
path: '/wallets'
})
},
submitAddWallet() {
if (
this.showAddWalletDialog.name &&
@@ -581,9 +594,10 @@ window.windowMixin = {
query: {wal: this.g.wallet.id}
})
} else {
const url = new URL(window.location.href)
url.searchParams.set('wal', this.g.wallet.id)
window.history.replaceState({}, '', url.toString())
this.$router.replace({
path: '/wallet',
query: {wal: this.g.wallet.id}
})
}
},
formatBalance(amount) {
@@ -843,6 +857,7 @@ window.windowMixin = {
? this.$q.localStorage.getItem('lnbits.darkMode')
: true
)
Chart.defaults.color = this.$q.dark.isActive ? '#fff' : '#000'
this.changeTheme(this.themeChoice)
this.applyBorder()
if (this.$q.dark.isActive) {
+5 -10
View File
@@ -103,14 +103,7 @@ window.app.component('lnbits-extension-list', {
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
props: [
'showAdmin',
'showNode',
'showExtensions',
'showUsers',
'showAudit',
'showPayments'
],
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
methods: {
isActive(path) {
return window.location.pathname === path
@@ -210,7 +203,7 @@ window.app.component('lnbits-qrcode', {
data() {
return {
custom: {
margin: 1,
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
@@ -566,7 +559,9 @@ window.app.component('username-password', {
username: this.userName,
password: this.password_1,
passwordRepeat: this.password_2,
reset_key: this.resetKey
reset_key: this.resetKey,
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
}
},
methods: {
@@ -8,6 +8,10 @@ window.app.component('lnbits-funding-sources', {
fundingSource => fundingSource[0] === item
)
return fundingSource ? fundingSource[1] : item
},
showQRValue(value) {
this.qrValue = value
this.showQRDialog = true
}
},
computed: {
@@ -17,7 +21,8 @@ window.app.component('lnbits-funding-sources', {
const tmpObj = {}
if (obj !== null) {
for (let [k, v] of Object.entries(obj)) {
tmpObj[k] = {label: v, value: null}
tmpObj[k] =
typeof v === 'string' ? {label: v, value: null} : v || {}
}
}
tmp.push([key, tmpObj])
@@ -31,13 +36,16 @@ window.app.component('lnbits-funding-sources', {
data() {
return {
hideInput: true,
showQRDialog: false,
qrValue: '',
rawFundingSources: [
['VoidWallet', 'Void Wallet', null],
[
'FakeWallet',
'Fake Wallet',
{
fake_wallet_secret: 'Secret'
fake_wallet_secret: 'Secret',
lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
}
],
[
@@ -137,7 +145,14 @@ window.app.component('lnbits-funding-sources', {
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name'
boltz_client_wallet: 'Wallet Name',
boltz_client_password: 'Wallet Password (can be empty)',
boltz_mnemonic: {
label: 'Liquid mnemonic (copy into greenwallet)',
readonly: true,
copy: true,
qrcode: true
}
}
],
[
@@ -196,6 +211,14 @@ window.app.component('lnbits-funding-sources', {
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
],
[
'StrikeWallet',
'Strike (alpha)',
{
strike_api_endpoint: 'API Endpoint',
strike_api_key: 'API Key'
}
]
]
}
@@ -178,6 +178,26 @@ window.app.component('payment-list', {
return LNbits.map.payment(obj)
})
})
.catch(err => {
this.paymentsTable.loading = false
if (g.user.admin) {
this.fetchPaymentsAsAdmin(this.currentWallet.id, params)
} else {
LNbits.utils.notifyApiError(err)
}
})
},
fetchPaymentsAsAdmin(walletId, params) {
params = (params || '') + '&wallet_id=' + walletId
return LNbits.api
.request('GET', '/api/v1/payments/all/paginated?' + params)
.then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total
this.payments = response.data.data.map(obj => {
return LNbits.map.payment(obj)
})
})
.catch(err => {
this.paymentsTable.loading = false
LNbits.utils.notifyApiError(err)
+2 -2
View File
@@ -101,8 +101,8 @@ window.app = Vue.createApp({
}
},
created() {
this.description = SITE_DESCRIPTION
this.allowedRegister = ALLOWED_REGISTER
this.description = this.SITE_DESCRIPTION
this.allowedRegister = this.LNBITS_NEW_ACCOUNTS_ALLOWED
this.authAction =
!this.allowedRegister ||
Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
+9
View File
@@ -193,6 +193,15 @@ const routes = [
scripts: ['/static/js/account.js']
}
},
{
path: '/wallets',
name: 'Wallets',
component: DynamicComponent,
props: {
fetchUrl: '/wallets',
scripts: ['/static/js/wallets.js']
}
},
{
path: '/node',
name: 'Node',
+1 -1
View File
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
return LNbits.utils.formatDate(this.ts)
},
dateFrom() {
return moment(this.date).fromNow()
return moment.utc(this.date).fromNow()
}
},
template: `
+46 -12
View File
@@ -8,9 +8,15 @@ window.PaymentsPageLogic = {
searchData: {
wallet_id: null,
payment_hash: null,
status: null,
memo: null
},
statusFilters: {
success: true,
pending: true,
failed: true,
incoming: true,
outgoing: true
},
chartData: {
showPaymentStatus: true,
showPaymentTags: true,
@@ -131,6 +137,7 @@ window.PaymentsPageLogic = {
if (this.searchDate.to) {
filter['time[le]'] = this.searchDate.to + 'T23:59:59'
}
this.paymentsTable.filter = filter
try {
@@ -148,7 +155,7 @@ window.PaymentsPageLogic = {
if (p.extra && p.extra.tag) {
p.tag = p.extra.tag
}
p.timeFrom = moment(p.created_at).fromNow()
p.timeFrom = moment.utc(p.created_at).fromNow()
p.outgoing = p.amount < 0
p.amount =
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
@@ -198,7 +205,38 @@ window.PaymentsPageLogic = {
this.fetchPayments()
},
handleFilterChanged() {
const {success, pending, failed, incoming, outgoing} = this.statusFilters
delete this.searchData['status[ne]']
delete this.searchData['status[eq]']
if (success && pending && failed) {
// do nothing, all statuses are selected
} else if (success && pending) {
this.searchData['status[ne]'] = 'failed'
} else if (success && failed) {
this.searchData['status[ne]'] = 'pending'
} else if (failed && pending) {
this.searchData['status[ne]'] = 'success'
} else if (success) {
this.searchData['status[eq]'] = 'success'
} else if (pending) {
this.searchData['status[eq]'] = 'pending'
} else if (failed) {
this.searchData['status[eq]'] = 'failed'
}
delete this.searchData['amount[ge]']
delete this.searchData['amount[le]']
if (incoming && outgoing) {
// do nothing
} else if (incoming) {
this.searchData['amount[ge]'] = '0'
} else if (outgoing) {
this.searchData['amount[le]'] = '0'
}
this.fetchPayments()
},
showDetailsToggle(payment) {
this.paymentDetails = payment
return (this.showDetails = !this.showDetails)
@@ -229,11 +267,10 @@ window.PaymentsPageLogic = {
'GET',
`/api/v1/payments/stats/count?${params}&count_by=status`
)
data.sort((a, b) => a.field - b.field)
this.searchOptions.status = data
.map(s => s.field)
.sort()
.reverse()
data.sort((a, b) => a.field - b.field).reverse()
this.searchOptions.status = data.map(s => s.field)
this.paymentsStatusChart.data.datasets[0].data = data.map(s => s.total)
this.paymentsStatusChart.data.labels = [...this.searchOptions.status]
@@ -419,11 +456,8 @@ window.PaymentsPageLogic = {
data: [],
backgroundColor: [
'rgb(0, 205, 86)',
'rgb(54, 162, 235)',
'rgb(255, 99, 132)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)'
'rgb(64, 72, 78)',
'rgb(255, 99, 132)'
],
hoverOffset: 4
}
+26 -4
View File
@@ -188,7 +188,7 @@ window.UsersPageLogic = {
.then(res => {
LNbits.utils
.confirmDialog(
'A reset key has been generated. Click OK to copy the rest key to your clipboard.'
this.$t('reset_key_generated') + ' ' + this.$t('reset_key_copy')
)
.onOk(() => {
const url = window.location.origin + '?reset_key=' + res.data
@@ -312,6 +312,23 @@ window.UsersPageLogic = {
.catch(LNbits.utils.notifyApiError)
})
},
deleteAllUserWallets(userId) {
LNbits.utils
.confirmDialog(this.$t('confirm_delete_all_wallets'))
.onOk(() => {
LNbits.api
.request('DELETE', `/users/api/v1/user/${userId}/wallets`)
.then(response => {
Quasar.Notify.create({
type: 'positive',
message: response.data.message,
icon: null
})
this.fetchWallets(userId)
})
.catch(LNbits.utils.notifyApiError)
})
},
copyWalletLink(walletId) {
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
this.copyText(url)
@@ -329,7 +346,7 @@ window.UsersPageLogic = {
.catch(LNbits.utils.notifyApiError)
},
fetchWallets(userId) {
LNbits.api
return LNbits.api
.request('GET', `/users/api/v1/user/${userId}/wallet`)
.then(res => {
this.wallets = res.data
@@ -378,8 +395,13 @@ window.UsersPageLogic = {
this.activeUser.show = false
}
},
showPayments(wallet_id) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id)
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
await this.showPayments(walletId)
},
showPayments(walletId) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === walletId)
this.paymentPage.show = true
},
searchUserBy(fieldName) {
+16 -17
View File
@@ -4,6 +4,7 @@ window.WalletPageLogic = {
return {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
parse: {
show: false,
invoice: null,
@@ -260,7 +261,9 @@ window.WalletPageLogic = {
this.receive.paymentReq = response.data.bolt11
this.receive.amountMsat = response.data.amount
this.receive.paymentHash = response.data.payment_hash
this.readNfcTag()
if (!this.receive.lnurl) {
this.readNfcTag()
}
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
@@ -335,9 +338,6 @@ window.WalletPageLogic = {
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
const data = response.data
@@ -375,6 +375,9 @@ window.WalletPageLogic = {
}
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
decodeQR(res) {
this.parse.data.request = res[0].rawValue
@@ -448,8 +451,8 @@ window.WalletPageLogic = {
createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expireDateFrom = moment(expireDate).fromNow()
cleanInvoice.createdDateFrom = moment(createdDate).fromNow()
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
cleanInvoice.expired = false // TODO
}
@@ -659,7 +662,7 @@ window.WalletPageLogic = {
}
}
Quasar.Notify.create({
message: 'Wallet and user updated.',
message: 'Wallet updated.',
type: 'positive',
timeout: 3500
})
@@ -1111,15 +1114,7 @@ window.WalletPageLogic = {
}
},
watch: {
'g.wallet.id'(newVal, oldVal) {
try {
this.fetchChartData()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
},
'g.updatePayments'(newVal, oldVal) {
console.log('updatePayments changed:', {newVal, oldVal})
this.parse.show = false
if (this.receive.paymentHash === this.g.updatePaymentsHash) {
this.receive.show = false
@@ -1145,8 +1140,12 @@ window.WalletPageLogic = {
}
},
'g.wallet': {
handler(newWallet) {
this.createdTasks()
handler() {
try {
this.createdTasks()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
},
deep: true
}
+89
View File
@@ -0,0 +1,89 @@
window.WalletsPageLogic = {
mixins: [window.windowMixin],
data() {
return {
user: null,
tab: 'wallets',
wallets: [],
showAddWalletDialog: {show: false},
walletsTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name',
sortable: true
},
{
name: 'currency',
align: 'center',
label: 'Currency',
field: 'currency',
sortable: true
},
{
name: 'updated_at',
align: 'right',
label: 'Last Updated',
field: 'updated_at',
sortable: true
}
],
pagination: {
sortBy: 'updated_at',
rowsPerPage: 12,
page: 1,
descending: true,
rowsNumber: 10
},
search: '',
hideEmpty: true,
loading: false
}
}
},
watch: {
'walletsTable.search': {
handler() {
const props = {}
if (this.walletsTable.search) {
props['search'] = this.walletsTable.search
}
this.getUserWallets()
}
}
},
methods: {
async getUserWallets(props) {
try {
this.walletsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.walletsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/wallet/paginated?${params}`,
null
)
this.wallets = data.data
this.walletsTable.pagination.rowsNumber = data.total
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.walletsTable.loading = false
}
},
goToWallet(walletId) {
window.location = `/wallet?wal=${walletId}`
},
formattedFiatAmount(amount, currency) {
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
},
formattedSatAmount(amount) {
return LNbits.utils.formatMsat(amount) + ' sat'
}
},
async created() {
await this.getUserWallets()
}
}
+3 -3
View File
@@ -8,11 +8,11 @@ $themes: (
marginal-text: #fff
),
'bitcoin': (
primary: #ff9853,
secondary: #ff7353,
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #2d293b,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
+45 -85
View File
@@ -1,4 +1,4 @@
// Axios v1.7.7 Copyright (c) 2024 Matt Zabriskie and contributors
/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
@@ -1266,23 +1266,6 @@
var toFiniteNumber = function toFiniteNumber(value, defaultValue) {
return value != null && Number.isFinite(value = +value) ? value : defaultValue;
};
var ALPHA = 'abcdefghijklmnopqrstuvwxyz';
var DIGIT = '0123456789';
var ALPHABET = {
DIGIT: DIGIT,
ALPHA: ALPHA,
ALPHA_DIGIT: ALPHA + ALPHA.toUpperCase() + DIGIT
};
var generateString = function generateString() {
var size = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 16;
var alphabet = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ALPHABET.ALPHA_DIGIT;
var str = '';
var length = alphabet.length;
while (size--) {
str += alphabet[Math.random() * length | 0];
}
return str;
};
/**
* If the thing is a FormData object, return true, otherwise return false.
@@ -1399,8 +1382,6 @@
findKey: findKey,
global: _global,
isContextDefined: isContextDefined,
ALPHABET: ALPHABET,
generateString: generateString,
isSpecCompliantForm: isSpecCompliantForm,
toJSONObject: toJSONObject,
isAsyncFn: isAsyncFn,
@@ -1735,7 +1716,7 @@
*
* @param {string} url The base of the url (e.g., http://www.google.com)
* @param {object} [params] The params to be appended
* @param {?object} options
* @param {?(object|Function)} options
*
* @returns {string} The formatted url
*/
@@ -1745,6 +1726,11 @@
return url;
}
var _encode = options && options.encode || encode;
if (utils$1.isFunction(options)) {
options = {
serialize: options
};
}
var serializeFn = options && options.serialize;
var serializedParams;
if (serializeFn) {
@@ -2642,60 +2628,14 @@
};
};
var isURLSameOrigin = platform.hasStandardBrowserEnv ?
// Standard browser envs have full support of the APIs needed to test
// whether the request URL is of the same origin as current location.
function standardBrowserEnv() {
var msie = platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent);
var urlParsingNode = document.createElement('a');
var originURL;
/**
* Parse a URL to discover its components
*
* @param {String} url The URL to be parsed
* @returns {Object}
*/
function resolveURL(url) {
var href = url;
if (msie) {
// IE needs attribute set twice to normalize properties
urlParsingNode.setAttribute('href', href);
href = urlParsingNode.href;
}
urlParsingNode.setAttribute('href', href);
// urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils
return {
href: urlParsingNode.href,
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
host: urlParsingNode.host,
search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '',
hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '',
hostname: urlParsingNode.hostname,
port: urlParsingNode.port,
pathname: urlParsingNode.pathname.charAt(0) === '/' ? urlParsingNode.pathname : '/' + urlParsingNode.pathname
};
}
originURL = resolveURL(window.location.href);
/**
* Determine if a URL shares the same origin as the current location
*
* @param {String} requestURL The URL to test
* @returns {boolean} True if URL shares the same origin, otherwise false
*/
return function isURLSameOrigin(requestURL) {
var parsed = utils$1.isString(requestURL) ? resolveURL(requestURL) : requestURL;
return parsed.protocol === originURL.protocol && parsed.host === originURL.host;
var isURLSameOrigin = platform.hasStandardBrowserEnv ? function (origin, isMSIE) {
return function (url) {
url = new URL(url, platform.origin);
return origin.protocol === url.protocol && origin.host === url.host && (isMSIE || origin.port === url.port);
};
}() :
// Non standard browser envs (web workers, react-native) lack needed support.
function nonStandardBrowserEnv() {
return function isURLSameOrigin() {
return true;
};
}();
}(new URL(platform.origin), platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent)) : function () {
return true;
};
var cookies = platform.hasStandardBrowserEnv ?
// Standard browser envs support document.cookie
@@ -2761,8 +2701,9 @@
*
* @returns {string} The combined full path
*/
function buildFullPath(baseURL, requestedURL) {
if (baseURL && !isAbsoluteURL(requestedURL)) {
function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) {
var isRelativeUrl = !isAbsoluteURL(requestedURL);
if (baseURL && isRelativeUrl || allowAbsoluteUrls == false) {
return combineURLs(baseURL, requestedURL);
}
return requestedURL;
@@ -2785,7 +2726,7 @@
// eslint-disable-next-line no-param-reassign
config2 = config2 || {};
var config = {};
function getMergedValue(target, source, caseless) {
function getMergedValue(target, source, prop, caseless) {
if (utils$1.isPlainObject(target) && utils$1.isPlainObject(source)) {
return utils$1.merge.call({
caseless: caseless
@@ -2799,11 +2740,11 @@
}
// eslint-disable-next-line consistent-return
function mergeDeepProperties(a, b, caseless) {
function mergeDeepProperties(a, b, prop, caseless) {
if (!utils$1.isUndefined(b)) {
return getMergedValue(a, b, caseless);
return getMergedValue(a, b, prop, caseless);
} else if (!utils$1.isUndefined(a)) {
return getMergedValue(undefined, a, caseless);
return getMergedValue(undefined, a, prop, caseless);
}
}
@@ -2860,8 +2801,8 @@
socketPath: defaultToConfig2,
responseEncoding: defaultToConfig2,
validateStatus: mergeDirectKeys,
headers: function headers(a, b) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), true);
headers: function headers(a, b, prop) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true);
}
};
utils$1.forEach(Object.keys(Object.assign({}, config1, config2)), function computeConfigValue(prop) {
@@ -3717,7 +3658,7 @@
});
}
var VERSION = "1.7.7";
var VERSION = "1.8.2";
var validators$1 = {};
@@ -3756,6 +3697,13 @@
return validator ? validator(value, opt, opts) : true;
};
};
validators$1.spelling = function spelling(correctSpelling) {
return function (value, opt) {
// eslint-disable-next-line no-console
console.warn("".concat(opt, " is likely a misspelling of ").concat(correctSpelling));
return true;
};
};
/**
* Assert object's properties type
@@ -3838,7 +3786,8 @@
_context.prev = 6;
_context.t0 = _context["catch"](0);
if (_context.t0 instanceof Error) {
Error.captureStackTrace ? Error.captureStackTrace(dummy = {}) : dummy = new Error();
dummy = {};
Error.captureStackTrace ? Error.captureStackTrace(dummy) : dummy = new Error();
// slice off the Error: ... line
stack = dummy.stack ? dummy.stack.replace(/^.+\n/, '') : '';
@@ -3901,6 +3850,17 @@
}
}
// Set config.allowAbsoluteUrls
if (config.allowAbsoluteUrls !== undefined) ; else if (this.defaults.allowAbsoluteUrls !== undefined) {
config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls;
} else {
config.allowAbsoluteUrls = true;
}
validator.assertOptions(config, {
baseUrl: validators.spelling('baseURL'),
withXsrfToken: validators.spelling('withXSRFToken')
}, true);
// Set config.method
config.method = (config.method || this.defaults.method || 'get').toLowerCase();
@@ -3968,7 +3928,7 @@
key: "getUri",
value: function getUri(config) {
config = mergeConfig(this.defaults, config);
var fullPath = buildFullPath(config.baseURL, config.url);
var fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls);
return buildURL(fullPath, config.params, config.paramsSerializer);
}
}]);
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -203,7 +203,8 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
if payment and payment.is_in:
status = await payment.check_status()
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
internal = "internal" if is_internal else ""
+65 -40
View File
@@ -89,11 +89,11 @@
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
v-text='$t("service_fee_max_badge", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}", denom: "{{ LNBITS_DENOMINATION }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
v-text='$t("service_fee_badge", { amount: "{{ LNBITS_SERVICE_FEE}}"})'
></span>
{%endif%}
<q-tooltip
@@ -179,12 +179,12 @@
>
<q-scroll-area style="height: 100%">
<q-item>
<q-item-section>
<q-item-section class="cursor-pointer" @click="goToWallets()">
<q-item-label
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
class="q-item__label q-item__label--header q-pa-none"
header
v-text="$t('wallets')"
v-text="$t('wallets') + ' (' + g.user.wallets.length + ')'"
></q-item-label>
</q-item-section>
<q-item-section side>
@@ -211,7 +211,6 @@
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>
@@ -248,7 +247,7 @@
@click="showAddWalletDialog.show = true"
>
<q-tooltip
><span v-text="$t('add_wallet')"></span
><span v-text="$t('add_new_wallet')"></span
></q-tooltip>
</q-btn>
<q-dialog
@@ -258,7 +257,9 @@
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">Wallet name</div>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
@@ -271,10 +272,14 @@
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
flat
label="Add wallet"
:label="$t('cancel')"
v-close-popup
></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
@@ -285,7 +290,7 @@
</q-card-section>
</q-card>
<q-card
v-for="wallet in g.user.wallets"
v-for="wallet in g.user.wallets.slice(0, g.user.extra.visible_wallet_count || 10)"
:key="wallet.id"
clickable
@click="selectWallet(wallet)"
@@ -331,6 +336,29 @@
</div>
</q-card-section>
</q-card>
<q-card
v-if="g.user.hiddenWalletsCount"
class="wallet-list-card"
>
<q-card-section
class="flex flex-center column full-height text-center"
>
<div>
<q-btn
round
color="primary"
icon="more_horiz"
@click="goToWallets()"
>
<q-tooltip
><span
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
></span
></q-tooltip>
</q-btn>
</div>
</q-card-section>
</q-card>
</div>
</q-scroll-area>
@@ -343,6 +371,7 @@
{% endblock %} {% block footer %}
<q-footer
v-if="'{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}"
>
@@ -389,42 +418,38 @@
</q-layout>
</div>
<script type="text/javascript">
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key]
})
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
}
</script>
{% include('components.vue') %} {% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
<script type="text/javascript">
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
const LNBITS_QR_LOGO = {{ LNBITS_QR_LOGO | tojson }}
const USE_DEFAULT_REACTION = {{ USE_DEFAULT_REACTION | tojson }}
const USE_DEFAULT_THEME = {{ USE_DEFAULT_THEME | tojson }}
const USE_DEFAULT_BORDER = {{ USE_DEFAULT_BORDER | tojson }}
const USE_DEFAULT_GRADIENT = {{ USE_DEFAULT_GRADIENT | lower | tojson }}
const USE_DEFAULT_BGIMAGE = "{{ USE_DEFAULT_BGIMAGE or None | tojson }}"
if (themes && themes.length) {
window.allowedThemes = themes.map(str => str.trim())
}
window.langs = [
{ value: 'en', label: 'English', display: '🇬🇧 EN' },
{ value: 'de', label: 'Deutsch', display: '🇩🇪 DE' },
{ value: 'es', label: 'Español', display: '🇪🇸 ES' },
{ value: 'jp', label: '日本語', display: '🇯🇵 JP' },
{ value: 'cn', label: '中文', display: '🇨🇳 CN' },
{ value: 'fr', label: 'Français', display: '🇫🇷 FR' },
{ value: 'it', label: 'Italiano', display: '🇮🇹 IT' },
{ value: 'pi', label: 'Pirate', display: '🏴‍☠️ PI' },
{ value: 'nl', label: 'Nederlands', display: '🇳🇱 NL' },
{ value: 'we', label: 'Cymraeg', display: '🏴󠁧󠁢󠁷󠁬󠁳󠁿 CY' },
{ value: 'pl', label: 'Polski', display: '🇵🇱 PL' },
{ value: 'pt', label: 'Português', display: '🇵🇹 PT' },
{ value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR' },
{ value: 'cs', label: 'Česky', display: '🇨🇿 CS' },
{ value: 'sk', label: 'Slovensky', display: '🇸🇰 SK' },
{ value: 'kr', label: '한국어', display: '🇰🇷 KR' },
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
{value: 'en', label: 'English', display: '🇬🇧 EN'},
{value: 'de', label: 'Deutsch', display: '🇩🇪 DE'},
{value: 'es', label: 'Español', display: '🇪🇸 ES'},
{value: 'jp', label: '日本語', display: '🇯🇵 JP'},
{value: 'cn', label: '中文', display: '🇨🇳 CN'},
{value: 'fr', label: 'Français', display: '🇫🇷 FR'},
{value: 'it', label: 'Italiano', display: '🇮🇹 IT'},
{value: 'pi', label: 'Pirate', display: '🏴‍☠️ PI'},
{value: 'nl', label: 'Nederlands', display: '🇳🇱 NL'},
{value: 'we', label: 'Cymraeg', display: '🏴󠁧󠁢󠁷󠁬󠁳󠁿 CY'},
{value: 'pl', label: 'Polski', display: '🇵🇱 PL'},
{value: 'pt', label: 'Português', display: '🇵🇹 PT'},
{value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR'},
{value: 'cs', label: 'Česky', display: '🇨🇿 CS'},
{value: 'sk', label: 'Slovensky', display: '🇸🇰 SK'},
{value: 'kr', label: '한국어', display: '🇰🇷 KR'},
{value: 'fi', label: 'Suomi', display: '🇫🇮 FI'}
]
window.LOCALE = 'en'
window.dateFormat = 'YYYY-MM-DD HH:mm'
+108 -27
View File
@@ -5,7 +5,10 @@
class="lnbits-drawer__q-list"
>
<q-item
v-for="walletRec in g.user.wallets"
v-for="walletRec in g.user.wallets.slice(
0,
g.user.extra.visible_wallet_count || 10
)"
:key="walletRec.id"
clickable
:active="g.wallet && g.wallet.id === walletRec.id"
@@ -43,6 +46,22 @@
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
</q-item-section>
</q-item>
<q-item
v-if="g.user.hiddenWalletsCount > 0"
clickable
@click="goToWallets()"
>
<q-item-section side>
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label
lines="1"
class="text-caption"
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
></q-item-label>
</q-item-section>
</q-item>
<q-item clickable @click="showForm = !showForm">
<q-item-section side>
<q-icon
@@ -55,7 +74,7 @@
<q-item-label
lines="1"
class="text-caption"
v-text="$t('add_wallet')"
v-text="$t('add_new_wallet')"
></q-item-label>
</q-item-section>
</q-item>
@@ -178,19 +197,19 @@
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showPayments" to="/payments">
<q-item-section side>
<q-icon
name="query_stats"
:color="isActive('/payments') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item to="/payments">
<q-item-section side>
<q-icon
name="query_stats"
:color="isActive('/payments') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showExtensions" to="/extensions">
<q-item-section side>
<q-icon
@@ -320,7 +339,26 @@
<q-item v-if="payment.preimage">
<q-item-section>
<q-item-label v-text="$t('payment_proof')"></q-item-label>
<q-item-label caption v-text="payment.preimage"></q-item-label>
<q-item-label
caption
v-text="
`${payment.preimage.slice(0, 12)}...${payment.preimage.slice(-12)}`
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-icon
name="content_copy"
@click="copyText(payment.preimage)"
size="1em"
color="grey"
class="cursor-pointer"
/>
</q-item-label>
<q-tooltip>
<span v-text="payment.preimage"></span>
</q-tooltip>
</q-item-section>
</q-item>
@@ -968,7 +1006,7 @@
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="row q-gutter-x-sm">
<q-btn
v-if="
props.row.isIn && props.row.isPending && props.row.bolt11
@@ -1057,7 +1095,16 @@
<template id="lnbits-funding-sources">
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('funding_sources')"></span>
<q-btn
round
flat
@click="this.hideInput = !this.hideInput"
:icon="this.hideInput ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
@@ -1094,19 +1141,41 @@
:type="hideInput ? 'password' : 'text'"
:label="prop.label"
:hint="prop.hint"
:readonly="prop.readonly || false"
>
<template v-slot:append>
<q-icon
:name="hideInput ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="this.hideInput = !this.hideInput"
></q-icon>
</template>
<q-btn
v-if="prop.copy"
@click="copyText(formData[key])"
icon="content_copy"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
<q-btn
v-if="prop.qrcode"
@click="showQRValue(formData[key])"
icon="qr_code"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
</q-input>
</div>
</div>
</div>
</q-list>
<q-dialog v-model="showQRDialog">
<q-card class="q-pa-md">
<q-card-section>
<lnbits-qrcode :value="qrValue" :size="200"></lnbits-qrcode>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
@@ -1164,7 +1233,7 @@
color="primary"
:disable="walletName == ''"
type="submit"
:label="$t('add_wallet')"
:label="$t('add_new_wallet')"
class="full-width q-mb-sm"
></q-btn>
<q-btn
@@ -1432,10 +1501,22 @@
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="`{{ static_url_for('static', 'images/keycloak-logo.png') }}`"
:src="
keycloakIcon
? keycloakIcon
: `{{ static_url_for('static', 'images/keycloak-logo.png') }}`
"
></q-img>
</q-avatar>
<div><span v-text="$t('signin_with_keycloak')"></span></div>
<div>
<span
v-text="
$t('signin_with_custom_org', {
custom_org: keycloakOrg
})
"
></span>
</div>
</q-btn>
</div>
</q-card-section>
+19 -16
View File
@@ -6,7 +6,7 @@
>
{% block page %}
<div class="text-center q-pa-md flex flex-center">
<div>
<div v-if="statusCode">
<div class="error-code" v-text="statusCode"></div>
<div class="error-message" v-text="message"></div>
@@ -65,19 +65,15 @@
goToExtension() {
window.location.href = `/extensions#${this.extension}`
},
logOut() {
LNbits.utils
.confirmDialog(
'Do you really want to logout?'
)
.onOk( async () => {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
})
async logOut() {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
},
computed: {
@@ -86,10 +82,17 @@
if (this.message.startsWith('Extension ')) return true
}
},
created() {
async created() {
this.err = '{{ err }}'
this.statusCode = '{{ status_code }}' || 404
const statusCode = '{{ status_code }}' || 404
this.message = String({{ message | tojson }}) || 'Page not found'
if (statusCode == 401) {
console.warn(`Unauthorized: ${this.message}`)
this.logOut()
return
}
this.statusCode = statusCode
if (this.isExtension) {
this.extension = this.message.match(/'([^']+)'/)[1]
}
+11 -6
View File
@@ -44,15 +44,20 @@
</q-layout>
</div>
<script type="text/javascript">
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key]
})
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
}
</script>
{% include('components.vue') %}{% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
<script>
const LNBITS_QR_LOGO = {{ LNBITS_QR_LOGO | tojson }}
</script>
<!---->
{% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
{% endfor %} {% block scripts %}{% endblock %} {% for url in
INCLUDED_COMPONENTS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
</body>
+75 -51
View File
@@ -1,15 +1,13 @@
import base64
import getpass
from base64 import b64decode, b64encode, urlsafe_b64decode, urlsafe_b64encode
from hashlib import md5, pbkdf2_hmac, sha256
from typing import Union
from Cryptodome import Random
from Cryptodome.Cipher import AES
BLOCK_SIZE = 16
def random_secret_and_hash() -> tuple[str, str]:
secret = Random.new().read(32)
def random_secret_and_hash(length: int = 32) -> tuple[str, str]:
secret = Random.new().read(length)
return secret.hex(), sha256(secret).hexdigest()
@@ -23,74 +21,100 @@ def fake_privkey(secret: str) -> str:
).hex()
def verify_preimage(preimage: str, payment_hash: str) -> bool:
preimage_bytes = bytes.fromhex(preimage)
calculated_hash = sha256(preimage_bytes).hexdigest()
return calculated_hash == payment_hash
class AESCipher:
"""This class is compatible with crypto-js/aes.js
"""
AES-256-CBC encryption/decryption with salt and base64 encoding.
:param key: The key to use for en-/decryption. It can be bytes, a hex or a string.
This class is compatible with crypto-js/aes.js
Encrypt and decrypt in Javascript using:
import AES from "crypto-js/aes.js";
import Utf8 from "crypto-js/enc-utf8.js";
AES.encrypt(decrypted, password).toString()
AES.decrypt(encrypted, password).toString(Utf8);
import AES from "crypto-js/aes.js";
import Utf8 from "crypto-js/enc-utf8.js";
AES.encrypt(decrypted, password).toString()
AES.decrypt(encrypted, password).toString(Utf8);
"""
def __init__(self, key=None, description=""):
self.key = key
self.description = description + " "
def __init__(self, key: Union[bytes, str], block_size: int = 16):
self.block_size = block_size
if isinstance(key, bytes):
self.key = key
return
try:
self.key = bytes.fromhex(key)
except ValueError:
pass
self.key = key.encode()
def pad(self, data):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
def pad(self, data: bytes) -> bytes:
length = self.block_size - (len(data) % self.block_size)
return data + (chr(length) * length).encode()
def unpad(self, data):
return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]
def unpad(self, data: bytes) -> bytes:
padding = data[-1]
# Ensure padding is within valid range else there is no padding
if padding <= 0 or padding >= self.block_size:
return data
return data[:-padding]
@property
def passphrase(self):
passphrase = self.key if self.key is not None else None
if passphrase is None:
passphrase = getpass.getpass(f"Enter {self.description}password:")
return passphrase
def bytes_to_key(self, data, salt, output=48):
def derive_iv_and_key(
self, salt: bytes, output_len: int = 32 + 16
) -> tuple[bytes, bytes]:
# extended from https://gist.github.com/gsakkis/4546068
assert len(salt) == 8, len(salt)
data += salt
assert len(salt) == 8, "Salt must be 8 bytes"
data = self.key + salt
key = md5(data).digest()
final_key = key
while len(final_key) < output:
while len(final_key) < output_len:
key = md5(key + data).digest()
final_key += key
return final_key[:output]
iv_key = final_key[:output_len]
return iv_key[32:], iv_key[:32]
def decrypt(self, encrypted: str, urlsafe: bool = False) -> str:
"""Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase
"""Decrypts a salted base64 encoded string using AES-256-CBC."""
if urlsafe:
encrypted_bytes = base64.urlsafe_b64decode(encrypted)
decoded = urlsafe_b64decode(encrypted)
else:
encrypted_bytes = base64.b64decode(encrypted)
decoded = b64decode(encrypted)
assert encrypted_bytes[0:8] == b"Salted__"
salt = encrypted_bytes[8:16]
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
if decoded[0:8] != b"Salted__":
raise ValueError("Invalid salt.")
salt = decoded[8:16]
encrypted_bytes = decoded[16:]
iv, key = self.derive_iv_and_key(salt, 32 + 16)
aes = AES.new(key, AES.MODE_CBC, iv)
try:
return self.unpad(aes.decrypt(encrypted_bytes[16:])).decode()
decrypted_bytes = aes.decrypt(encrypted_bytes)
except Exception as exc:
raise ValueError("Could not decrypt payload") from exc
unpadded = self.unpad(decrypted_bytes)
if len(unpadded) == 0:
raise ValueError("Unpadding resulted in empty data.")
try:
return unpadded.decode()
except UnicodeDecodeError as exc:
raise ValueError("Wrong passphrase") from exc
raise ValueError("Decryption resulted in invalid UTF-8 data.") from exc
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
passphrase = self.passphrase
"""
Encrypts a string using AES-256-CBC and returns a salted base64 encoded string.
"""
salt = Random.new().read(8)
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
iv, key = self.derive_iv_and_key(salt, 32 + 16)
aes = AES.new(key, AES.MODE_CBC, iv)
encoded = b"Salted__" + salt + aes.encrypt(self.pad(message))
return (
base64.urlsafe_b64encode(encoded) if urlsafe else base64.b64encode(encoded)
).decode()
msg = self.pad(message)
encrypted = aes.encrypt(msg)
salted = b"Salted__" + salt + encrypted
encoded = urlsafe_b64encode(salted) if urlsafe else b64encode(salted)
return encoded.decode()
+57 -6
View File
@@ -1,3 +1,5 @@
import asyncio
import statistics
from typing import Optional
import httpx
@@ -186,6 +188,54 @@ def allowed_currencies() -> list[str]:
return list(currencies.keys())
def apply_trimmed_mean_filter(
rates: list[tuple[str, float]], threshold_percentage: float = 0.01
) -> list[tuple[str, float]]:
"""
Apply trimmed mean filtering to remove outliers from exchange rates.
Args:
rates: List of (provider_name, rate_value) tuples
threshold_percentage: Percentage threshold for outlier removal (default 1%)
Returns:
Filtered list of rates with outliers removed
"""
if len(rates) < 3:
# Need at least 3 rates to apply filtering
return rates
rates_values = [r[1] for r in rates]
median_value = statistics.median(rates_values)
# Filter out values that are more than threshold_percentage away from median
filtered_rates = []
for rate in rates:
provider_name, value = rate
deviation = abs(value - median_value) / median_value
if deviation <= threshold_percentage:
logger.debug(
f"Keeping {provider_name}: {value} (deviation: {deviation:.4f})"
)
filtered_rates.append(rate)
else:
logger.debug(
f"Removing outlier {provider_name}: {value} "
f"(deviation: {deviation:.4f})"
)
# If we still have at least 2 rates after filtering, use them
if len(filtered_rates) >= 2:
logger.debug(f"Filtered rates: {filtered_rates}")
return filtered_rates
else:
# Fall back to median if filtering removed too many values
logger.debug("Filtering removed too many values, using median instead")
# Find the rate closest to median
closest_rate = min(rates, key=lambda x: abs(x[1] - median_value))
return [closest_rate]
async def btc_rates(currency: str) -> list[tuple[str, float]]:
if currency.upper() not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
@@ -212,7 +262,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(url, timeout=0.5)
r = await client.get(url, timeout=3)
r.raise_for_status()
if not provider.path:
@@ -230,13 +280,14 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return None
# OK to be in squence: fetch_price times out after 0.5 seconds
results = [
await fetch_price(provider)
for provider in settings.lnbits_exchange_rate_providers
calls = [
fetch_price(provider) for provider in settings.lnbits_exchange_rate_providers
]
results = await asyncio.gather(*calls)
return [r for r in results if r is not None]
all_rates = [r for r in results if r is not None]
return apply_trimmed_mean_filter(all_rates)
async def btc_price(currency: str) -> float:
+2
View File
@@ -28,6 +28,7 @@ from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet
from .strike import StrikeWallet
from .void import VoidWallet
from .zbd import ZBDWallet
@@ -72,6 +73,7 @@ __all__ = [
"OpenNodeWallet",
"PhoenixdWallet",
"SparkWallet",
"StrikeWallet",
"VoidWallet",
"ZBDWallet",
]
+21 -14
View File
@@ -1,7 +1,7 @@
import asyncio
import hashlib
import json
from typing import AsyncGenerator, Dict, Optional
from typing import AsyncGenerator, Optional
import httpx
from loguru import logger
@@ -72,10 +72,10 @@ class AlbyWallet(Wallet):
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
**_,
) -> InvoiceResponse:
# https://api.getalby.com/invoices
data: Dict = {"amount": f"{amount}"}
data: dict = {"amount": f"{amount}"}
if description_hash:
data["description_hash"] = description_hash.hex()
elif unhashed_description:
@@ -95,25 +95,31 @@ class AlbyWallet(Wallet):
if r.is_error:
error_message = data["message"] if "message" in data else r.text
return InvoiceResponse(False, None, None, error_message)
return InvoiceResponse(ok=False, error_message=error_message)
checking_id = data["payment_hash"]
payment_request = data["payment_request"]
return InvoiceResponse(True, checking_id, payment_request, None)
preimage = data.get("payment_preimage")
return InvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=payment_request,
preimage=preimage,
)
except KeyError as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, "Server error: 'missing required fields'"
ok=False, error_message="Server error: 'missing required fields'"
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.endpoint}."
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
@@ -129,30 +135,31 @@ class AlbyWallet(Wallet):
if r.is_error:
error_message = data["message"] if "message" in data else r.text
return PaymentResponse(None, None, None, None, error_message)
return PaymentResponse(error_message=error_message)
checking_id = data["payment_hash"]
# todo: confirm with bitkarrot that having the minus is fine
# other funding sources return a positive fee value
fee_msat = -data["fee"]
preimage = data["payment_preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
None, None, None, None, "Server error: 'missing required fields'"
error_message="Server error: 'missing required fields'"
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return PaymentResponse(
None, None, None, None, "Server error: 'invalid json response'"
error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
None, None, None, None, f"Unable to connect to {self.endpoint}."
error_message=f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+1
View File
@@ -20,6 +20,7 @@ class InvoiceResponse(NamedTuple):
checking_id: str | None = None # payment_hash, rpc_id
payment_request: str | None = None
error_message: str | None = None
preimage: str | None = None
@property
def success(self) -> bool:
+12 -7
View File
@@ -135,7 +135,7 @@ class BlinkWallet(Wallet):
)
if len(errors) > 0:
error_message = errors[0].get("message")
return InvoiceResponse(False, None, None, error_message)
return InvoiceResponse(ok=False, error_message=error_message)
payment_request = (
response.get("data", {})
@@ -150,15 +150,18 @@ class BlinkWallet(Wallet):
.get("paymentHash", None)
)
return InvoiceResponse(True, checking_id, payment_request, None)
# TODO: add preimage to response
return InvoiceResponse(
ok=True, checking_id=checking_id, payment_request=payment_request
)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.endpoint}."
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(
@@ -185,19 +188,21 @@ class BlinkWallet(Wallet):
)
if len(errors) > 0:
error_message = errors[0].get("message")
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(ok=False, error_message=error_message)
checking_id = bolt11.decode(bolt11_invoice).payment_hash
payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat
preimage = payment_status.preimage
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11_invoice}")
logger.warning(exc)
return PaymentResponse(
None, None, None, None, f"Unable to connect to {self.endpoint}."
error_message=f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+50 -3
View File
@@ -63,6 +63,27 @@ class BoltzWallet(Wallet):
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0
# Auto-create wallet if running in Docker mode
async def _init_boltz_wallet():
try:
wallet_name = settings.boltz_client_wallet or "lnbits"
mnemonic = await self._fetch_wallet(wallet_name)
if mnemonic:
logger.info(
"✅ Mnemonic found for Boltz wallet, saving to settings"
)
settings.boltz_mnemonic = mnemonic
from lnbits.core.crud.settings import set_settings_field
await set_settings_field("boltz_mnemonic", mnemonic)
else:
logger.warning("⚠️ No mnemonic returned from Boltz")
except Exception as e:
logger.error(f"❌ Failed to auto-create Boltz wallet: {e}")
self._init_wallet_task = asyncio.create_task(_init_boltz_wallet())
async def status(self) -> StatusResponse:
try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
@@ -70,9 +91,10 @@ class BoltzWallet(Wallet):
request, metadata=self.metadata
)
except AioRpcError as exc:
logger.warning(exc)
return StatusResponse(
exc.details()
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
"make sure you have macaroon and certificate configured,"
"unless your client runs without",
0,
)
@@ -193,7 +215,10 @@ class BoltzWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
boltzrpc_pb2.GetSwapInfoRequest(
payment_hash=bytes.fromhex(checking_id)
),
metadata=self.metadata,
)
swap = response.swap
except AioRpcError:
@@ -225,3 +250,25 @@ class BoltzWallet(Wallet):
" 5 seconds"
)
await asyncio.sleep(5)
async def _fetch_wallet(self, wallet_name: str) -> Optional[str]:
try:
request = boltzrpc_pb2.GetWalletRequest(name=wallet_name)
response = await self.rpc.GetWallet(request, metadata=self.metadata)
logger.info(f"Wallet '{wallet_name}' already exists with ID {response.id}")
return settings.boltz_mnemonic
except AioRpcError as exc:
details = exc.details() or "unknown error"
if exc.code() != grpc.StatusCode.NOT_FOUND:
logger.error(f"Error checking wallet existence: {details}")
raise
logger.info(f"Creating new wallet '{wallet_name}'")
params = boltzrpc_pb2.WalletParams(
name=wallet_name,
currency=boltzrpc_pb2.LBTC,
password=settings.boltz_client_password,
)
create_request = boltzrpc_pb2.CreateWalletRequest(params=params)
response = await self.rpc.CreateWallet(create_request, metadata=self.metadata)
return response.mnemonic
+198 -20
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package boltzrpc;
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc";
option go_package = "github.com/BoltzExchange/boltz-client/v2/pkg/boltzrpc";
import "google/protobuf/empty.proto";
service Boltz {
@@ -122,6 +122,23 @@ service Boltz {
*/
rpc GetWallet (GetWalletRequest) returns (Wallet);
/*
Calculates the fee for an equivalent `WalletSend` request.
If `address` is left empty, a dummy swap address will be used, allowing for a fee estimation of a swap lockup transaction.
*/
rpc GetWalletSendFee (WalletSendRequest) returns (WalletSendFee);
/*
Returns recent transactions from a wallet.
*/
rpc ListWalletTransactions (ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
/*
Increase the fee of a transaction using RBF.
The transaction has to belong to one of the clients wallets.
*/
rpc BumpTransaction (BumpTransactionRequest) returns (BumpTransactionResponse);
/*
Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
*/
@@ -132,6 +149,16 @@ service Boltz {
*/
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
/*
Send coins from a wallet. Only the confirmed balance can be spent.
*/
rpc WalletSend (WalletSendRequest) returns (WalletSendResponse);
/*
Get a new address of the wallet.
*/
rpc WalletReceive (WalletReceiveRequest) returns (WalletReceiveResponse);
/*
Gracefully stops the daemon.
*/
@@ -265,11 +292,12 @@ message SwapInfo {
repeated ChannelId chan_ids = 14;
optional string blinding_key = 15;
int64 created_at = 16;
optional uint64 service_fee = 17;
optional int64 service_fee = 17;
optional uint64 onchain_fee = 18;
// internal wallet which was used to pay the swap
optional uint64 wallet_id = 20;
uint64 tenant_id = 21;
bool is_auto = 23;
}
enum SwapType {
@@ -322,7 +350,8 @@ message ReverseSwapInfo {
string redeem_script = 7;
string invoice = 8;
string claim_address = 9;
int64 onchain_amount = 10;
uint64 onchain_amount = 10;
uint64 invoice_amount = 25;
uint32 timeout_block_height = 11;
string lockup_transaction_id = 12;
string claim_transaction_id = 13;
@@ -331,11 +360,12 @@ message ReverseSwapInfo {
optional string blinding_key = 16;
int64 created_at = 17;
optional int64 paid_at = 23; // the time when the invoice was paid
optional uint64 service_fee = 18;
optional int64 service_fee = 18;
optional uint64 onchain_fee = 19;
optional uint64 routing_fee_msat = 20;
bool external_pay = 21;
uint64 tenant_id = 22;
bool is_auto = 24;
}
message BlockHeights {
@@ -406,17 +436,44 @@ enum IncludeSwaps {
AUTO = 2;
}
message AnySwapInfo {
string id = 1;
SwapType type = 2;
Pair pair = 3;
SwapState state = 4;
optional string error = 5;
string status = 6;
// The expected amount to be sent to the lockup address for submarine and chain swaps and
// the invoice amount for reverse swaps.
uint64 from_amount = 7;
// `from_amount` minus the service and network fee.
uint64 to_amount = 13;
int64 created_at = 8;
optional int64 service_fee = 9;
// inclues the routing fee for reverse swaps
optional uint64 onchain_fee = 10;
bool is_auto = 11;
uint64 tenant_id = 12;
}
message ListSwapsRequest {
optional Currency from = 1;
optional Currency to = 2;
optional SwapState state = 4;
IncludeSwaps include = 5;
optional uint64 limit = 6;
optional uint64 offset = 7;
// wether to return swaps in the shared `all_swaps` list or in the detailed lists.
// the `limit` and `offset` are only considered when `unify` is true.
optional bool unify = 8;
}
message ListSwapsResponse {
repeated SwapInfo swaps = 1;
repeated CombinedChannelSwapInfo channel_creations = 2;
repeated CombinedChannelSwapInfo channel_creations = 2 [deprecated = true];
repeated ReverseSwapInfo reverse_swaps = 3;
repeated ChainSwapInfo chain_swaps = 4;
// populated when `unify` is set to true in the request
repeated AnySwapInfo all_swaps = 5;
}
message GetStatsRequest {
@@ -448,7 +505,12 @@ message ClaimSwapsResponse {
}
message GetSwapInfoRequest {
string id = 1;
string id = 1 [deprecated = true];
oneof identifier {
string swap_id = 2;
// Only implemented for submarine swaps
bytes payment_hash = 3;
}
}
message GetSwapInfoResponse {
SwapInfo swap = 1;
@@ -471,22 +533,29 @@ message DepositResponse {
}
message CreateSwapRequest {
// amount of sats to be received on lightning.
// related: `invoice` field
uint64 amount = 1;
Pair pair = 2;
// not yet supported
// repeated string chan_ids = 3;
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise.
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field
// or the first internal wallet with the correct currency otherwise.
bool send_from_internal = 4;
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
optional string refund_address = 5;
// wallet to pay swap from. only used if `send_from_internal` is set to true
optional uint64 wallet_id = 6;
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node
// bolt11 invoice, lnurl, or lnaddress to use for the swap.
// required in standalone mode.
// when connected to a lightning node, a new invoice for `amount` sats will be fetched
// the `amount` field has to be populated in case of a lnurl and lnaddress
optional string invoice = 7;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool zero_conf = 8;
optional bool zero_conf = 8 [deprecated = true];
// Fee rate to use when sending from internal wallet
optional double sat_per_vbyte = 9;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 10;
}
message CreateSwapResponse {
string id = 1;
@@ -530,6 +599,16 @@ message CreateReverseSwapRequest {
optional bool external_pay = 8;
// Description of the invoice which will be created for the swap
optional string description = 9;
// Description hash of the invoice which will be created for the swap. Takes precedence over `description`
optional bytes description_hash = 10;
// Expiry of the reverse swap invoice in seconds
optional uint64 invoice_expiry = 11;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 12;
// The routing fee limit for paying the lightning invoice in ppm (parts per million)
optional uint64 routing_fee_limit_ppm = 13;
}
message CreateReverseSwapResponse {
string id = 1;
@@ -545,7 +624,8 @@ message CreateReverseSwapResponse {
message CreateChainSwapRequest {
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
uint64 amount = 1;
// If left empty, any amount within the limits will be accepted.
optional uint64 amount = 1;
Pair pair = 2;
// Address where funds will be swept to if the swap succeeds
optional string to_address = 3;
@@ -561,9 +641,12 @@ message CreateChainSwapRequest {
optional bool accept_zero_conf = 7;
// If set, the daemon will not pay the swap from an internal wallet.
optional bool external_pay = 8;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool lockup_zero_conf = 9;
optional bool lockup_zero_conf = 9 [deprecated = true];
// Fee rate to use when sending from internal wallet
optional double sat_per_vbyte = 10;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 11;
}
message ChainSwapInfo {
@@ -574,7 +657,7 @@ message ChainSwapInfo {
string status = 5;
string preimage = 6;
bool is_auto = 8;
optional uint64 service_fee = 9;
optional int64 service_fee = 9;
double service_fee_percent = 10;
optional uint64 onchain_fee = 11;
int64 created_at = 12;
@@ -614,9 +697,9 @@ message LightningChannel {
}
message SwapStats {
uint64 total_fees = 1;
int64 total_fees = 1;
uint64 total_amount = 2;
uint64 avg_fees = 3;
int64 avg_fees = 3;
uint64 avg_amount = 4;
uint64 count = 5;
uint64 success_count = 6;
@@ -687,6 +770,75 @@ message GetWalletRequest {
optional uint64 id = 2;
}
message WalletSendFee {
// amount of sats which would be sent
uint64 amount = 1;
uint64 fee = 2;
// the fee rate used for the estimation in sat/vbyte
double fee_rate = 3;
}
message ListWalletTransactionsRequest {
uint64 id = 1;
optional bool exclude_swap_related = 2;
optional uint64 limit = 3;
optional uint64 offset = 4;
}
enum TransactionType {
UNKNOWN = 0;
LOCKUP = 1;
REFUND = 2;
CLAIM = 3;
CONSOLIDATION = 4;
}
message WalletTransaction {
string id = 1;
// balance change of the wallet in satoshis.
// its the sum of all output values minus the sum of all input values which are controlled by the wallet.
// positive values indicate incoming transactions, negative values outgoing transactions
int64 balance_change = 2;
int64 timestamp = 3;
repeated TransactionOutput outputs = 4;
uint32 block_height = 6;
// additional informations about the tx (type, related swaps etc.)
repeated TransactionInfo infos = 7;
}
message BumpTransactionRequest {
oneof previous {
// Id of the transaction to bump. The transaction has to belong to one of the clients wallets
string tx_id = 1;
// Depending on the state of the swap, the lockup, refund or claim transaction will be bumped
string swap_id = 2;
}
// Fee rate for the new transaction. if not specified, the daemon will query the fee rate from the configured provider
// and bump the fee by at least 1 sat/vbyte.
optional double sat_per_vbyte = 3;
}
message BumpTransactionResponse {
string tx_id = 1;
}
message TransactionInfo {
// will be populated for LOCKUP, REFUND and CLAIM
optional string swap_id = 1;
TransactionType type = 2;
}
message TransactionOutput {
string address = 1;
uint64 amount = 2;
// wether the address is controlled by the wallet
bool is_our_address = 3;
}
message ListWalletTransactionsResponse {
repeated WalletTransaction transactions = 1;
}
message GetWalletCredentialsRequest {
uint64 id = 1;
optional string password = 2;
@@ -696,6 +848,31 @@ message RemoveWalletRequest {
uint64 id = 1;
}
message WalletSendRequest {
uint64 id = 1;
string address = 2;
// Amount of satoshis to be sent to 'address`
uint64 amount = 3;
// Fee rate to use for the transaction
optional double sat_per_vbyte = 4;
// Sends all available funds to the address. The `amount` field is ignored.
optional bool send_all = 5;
// whether `address` is the lockup of a swap.
optional bool is_swap_address = 6;
}
message WalletSendResponse {
string tx_id = 1;
}
message WalletReceiveRequest {
uint64 id = 1;
}
message WalletReceiveResponse {
string address = 1;
}
message Wallet {
uint64 id = 1;
@@ -720,6 +897,7 @@ message Subaccount {
Balance balance = 1;
uint64 pointer = 2;
string type = 3;
repeated string descriptors = 4;
}
message RemoveWalletResponse {}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,3 +1,3 @@
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/refs/heads/master/pkg/boltzrpc/boltzrpc.proto -O boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. boltz_grpc_files/boltzrpc.proto
+12 -14
View File
@@ -182,15 +182,16 @@ else:
)
)
# TODO: add preimage
return InvoiceResponse(
True,
breez_invoice.ln_invoice.payment_hash,
breez_invoice.ln_invoice.bolt11,
None,
ok=True,
checking_id=breez_invoice.ln_invoice.payment_hash,
payment_request=breez_invoice.ln_invoice.bolt11,
# preimage=breez_invoice.ln_invoice.payment_preimage,
)
except Exception as e:
logger.warning(e)
return InvoiceResponse(False, None, None, str(e))
return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
@@ -217,22 +218,19 @@ else:
except Exception as ex:
logger.info(ex)
# assume that payment failed?
return PaymentResponse(
False, None, None, None, f"payment failed: {exc}"
)
return PaymentResponse(ok=False, error_message=f"payment failed: {exc}")
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
return PaymentResponse(False, None, None, None, "payment is pending")
return PaymentResponse(ok=False, error_message="payment is pending")
# let's use the payment_hash as the checking_id
checking_id = invoice.payment_hash
return PaymentResponse(
True,
checking_id,
payment.fee_msat,
payment.details.data.payment_preimage,
None,
ok=True,
checking_id=checking_id,
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:

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