Compare commits

...
Author SHA1 Message Date
dni ⚡andGitHub 793c7c45ed chore: update version to v1.3.0-rc2 (#3287) 2025-07-21 11:32:53 +03:00
dni ⚡andGitHub ff535ae065 feat: add get_pr_from_lnurl to lnurl services (#3286) 2025-07-21 10:28:14 +02:00
dni ⚡andGitHub 01fdc1defb fix: frontend withdraw error message (#3282) 2025-07-21 10:34:50 +03:00
dce2bfb440 [feat] User notifications backend (#3280)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-07-17 16:11:40 +02:00
dni ⚡andGitHub a36ab2d408 feat: update lnurl lib refactor lnurl endpoints (#3271) 2025-07-17 17:03:44 +03:00
dni ⚡andGitHub 98f732fb6f chore: update fastapi an starlette (#3277) 2025-07-17 16:41:54 +03:00
dni ⚡andGitHub 65864cb216 chore: use modern websockets instead of legacy (#3278) 2025-07-17 15:02:29 +03:00
dni ⚡andGitHub 114e7b8c3c chore: update to version v1.3.0-rc1 (#3273) 2025-07-17 14:59:12 +03:00
dni ⚡andGitHub fbfab880f3 chore: replace passlib with bcrypt (#3279) 2025-07-17 14:58:43 +03:00
dni ⚡andGitHub 133afff8ee fix: catch init fiat providers (#3281) 2025-07-17 14:58:03 +03:00
dni ⚡andGitHub 851c952c6d chore: update vue-i18n dependabot (#3276) 2025-07-17 12:23:55 +03:00
dni ⚡andGitHub 8b29148085 fix: frontend bgimagechoice can be null (#3270) 2025-07-15 12:36:39 +03:00
dni ⚡ ad48092549 chore: bundle 2025-07-15 11:16:42 +02:00
ee449ca612 chore: udate v1.2.1 fi.js (#3268)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:19 +02:00
Vlad Stananddni ⚡ 73f45ae859 feat: better server start logging (#3266) 2025-07-15 11:15:18 +02:00
Vlad Stananddni ⚡ 094a9fc1a1 [feat] UI for user notifications (#3267) 2025-07-15 11:15:17 +02:00
dni ⚡ 5e9919c5c5 feat: toggle holdinvoice depending on fundingsource Feature (#3262) 2025-07-15 11:15:16 +02:00
f91c933919 feat: HodlInvoices (Rebase) (#2869)
Co-authored-by: pseudozach <git@pseudozach.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:14 +02:00
dni ⚡ 6e9f451419 feat: introduce self.features to wallets, refactor feature nodemanager (#3260) 2025-07-15 11:15:13 +02:00
dni ⚡ 256a8098c6 chore: get rid of mypy notes and typecheck more functions (#3257) 2025-07-15 11:15:12 +02:00
Arcanddni ⚡ 1a02095758 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:15:10 +02:00
Tiago Vasconcelosanddni ⚡ ec5b7493b3 fix: default theme settings (#3256) 2025-07-15 11:14:33 +02:00
cdc1a6dc8f feat: Add support for the new CLNRest as a funding source (#2778)
Co-authored-by: Amperstrand <amperstrand@localhost>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:32 +02:00
Arcanddni ⚡ 7d573ef859 chore: tooltip for ad (#3255) 2025-07-15 11:14:30 +02:00
Vlad Stananddni ⚡ 4bd2a4fba9 chore: upgrade protobuf (#3252) 2025-07-15 11:14:29 +02:00
dni ⚡ 209c251fb6 fix: update and fixes for BreezWallet (#3246) 2025-07-15 11:14:28 +02:00
dni ⚡ 69a7ef74fd CI: fix using all optional dependecies (#3251) 2025-07-15 11:14:27 +02:00
dni ⚡andVlad Stan 35e2c4b0a7 fix: update success status (#3244)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:25 +02:00
dni ⚡ 2c6dce808e feat: run_interval in tasks and refactor check_pending_payments (#3245) 2025-07-15 11:14:24 +02:00
Vlad Stananddni ⚡ fa89313e6f [fix] bandit assert warnings (#3243)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:14:23 +02:00
Vlad Stananddni ⚡ 76ecf113c3 [fix] bandit sql warnings (#3242) 2025-07-15 11:14:22 +02:00
Vlad Stananddni ⚡ e0749e186e [fix] Small bandit changes (#3241) 2025-07-15 11:14:20 +02:00
0529ee2835 feat: add Breez *Liquid* SDK funding source (#2681)
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:19 +02:00
Vlad Stananddni ⚡ 09b91f9f79 refactor: extract create_payment_request (#3240) 2025-07-15 11:14:18 +02:00
dni ⚡andVlad Stan 90ab642dcd fix: phoenixd pending payments (#3075)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:16 +02:00
88cf1ac853 feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:15 +02:00
Vlad Stananddni ⚡ 9aa2194d94 refactor: use while settings.lnbits_running instead of while True 2025-07-15 11:14:04 +02:00
dni ⚡ ba0f7a01ef chore: reduce max complexity lint (#3230) 2025-07-15 11:14:03 +02:00
Tiago Vasconcelosanddni ⚡ 5765ea0276 [Fix] Display fee in sats (#3235) 2025-07-15 11:14:02 +02:00
Arcanddni ⚡ 88b01e6079 feat: theme fix for elsal, cyber, freedom (#3259) 2025-07-11 12:39:03 +02:00
Arcanddni ⚡ b0d0901969 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-11 12:39:02 +02:00
dni ⚡ 6563610b6c chore: update to version 1.2.1 2025-07-11 12:38:33 +02:00
dni ⚡ c4aa0b598c chore: update to version 1.2.0 2025-07-08 10:22:22 +02:00
dni ⚡ aab76e691d chore: update to version v1.2.0-rc3 (#3231) 2025-07-08 10:17:30 +02:00
Vlad Stananddni ⚡ 3ed30be93e fix: condition for available, but not installed extension (#3232) 2025-07-08 10:17:29 +02:00
Vlad Stananddni ⚡ 37f99e7f96 doc: LNBITS_EXTENSIONS_PATH (#3229) 2025-07-08 10:17:28 +02:00
dni ⚡ a16078a6ba chore: update python packages + formatting + cleanup (#3221) 2025-07-08 10:17:27 +02:00
dni ⚡ c4c03d96a3 test: add regtest testcase for no route failure (#3189) 2025-07-08 10:17:25 +02:00
Vlad Stananddni ⚡ ab3248da15 fix: url_for should not return extension upgrades temp paths (#3227) 2025-07-08 10:17:24 +02:00
dni ⚡ b4b37cd733 fix: circular import on handle_fiat_payments (#3226) 2025-07-08 10:17:23 +02:00
dni ⚡ 26eb7a449c feat: add internal_invoice_queue_put to get rid of dynamic import (#3223) 2025-07-08 10:17:21 +02:00
Vlad Stananddni ⚡ 695e9b6471 [feat] Add stripe payments (#3184) 2025-07-08 10:17:19 +02:00
blackcoffeexbtanddni ⚡ 5c9511ccfe Label tweaks (#3224) 2025-07-08 10:17:18 +02:00
Vlad Stananddni ⚡ c7b7832a88 feat: add external id for users (#3219) 2025-07-08 10:17:16 +02:00
164 changed files with 11335 additions and 9253 deletions
+21 -1
View File
@@ -40,7 +40,7 @@ PORT=5000
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet" # LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -61,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet # CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -153,6 +167,12 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true # BREEZ_USE_TRAMPOLINE=true
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
# Google OAuth Config # Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token # Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
+1 -3
View File
@@ -43,9 +43,7 @@ runs:
shell: bash shell: bash
run: | run: |
poetry env use python${{ inputs.python-version }} poetry env use python${{ inputs.python-version }}
poetry install poetry install --all-extras
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }} - name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }} if: ${{ (inputs.npm == 'true') }}
+8
View File
@@ -159,6 +159,14 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
Example:
```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
```
Build the image yourself. Build the image yourself.
```sh ```sh
+31
View File
@@ -12,6 +12,23 @@ A backend wallet can be configured using the following LNbits environment variab
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md). You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
- `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
### CoreLightning ### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -19,6 +36,8 @@ You can [compare the LNbits compatible Lightning Network funding sources here](w
### CoreLightning REST ### CoreLightning REST
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/ - `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex - `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
@@ -135,6 +154,18 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex - `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex - `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Breez Liquid SDK
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
- `BREEZ_LIQUID_SEED`: ...
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
- `LNBITS_RESERVE_FEE_MIN`: ...
- `LNBITS_RESERVE_FEE_PERCENT`: ...
### Cliche Wallet ### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
+5 -8
View File
@@ -9,16 +9,13 @@ from .decorators import (
from .exceptions import InvoiceError, PaymentError from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
# decorators "InvoiceError",
"require_admin_key", "PaymentError",
"require_invoice_key",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",
# services
"pay_invoice",
"create_invoice", "create_invoice",
# exceptions "pay_invoice",
"PaymentError", "require_admin_key",
"InvoiceError", "require_invoice_key",
] ]
+15 -16
View File
@@ -4,6 +4,7 @@ import importlib
import os import os
import shutil import shutil
import sys import sys
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
@@ -26,7 +27,8 @@ from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_notification from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import ( from lnbits.core.tasks import (
audit_queue, audit_queue,
collect_exchange_rates_data, collect_exchange_rates_data,
@@ -65,17 +67,13 @@ from .middleware import (
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .requestvars import g from .tasks import internal_invoice_listener, invoice_listener, run_interval
from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
)
async def startup(app: FastAPI): async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True settings.lnbits_running = True
# wait till migration is done # wait till migration is done
await migrate_databases() await migrate_databases()
@@ -106,7 +104,7 @@ async def startup(app: FastAPI):
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks()
enqueue_notification( enqueue_admin_notification(
NotificationType.server_start_stop, NotificationType.server_start_stop,
{ {
"message": "LNbits server started.", "message": "LNbits server started.",
@@ -114,10 +112,13 @@ async def startup(app: FastAPI):
}, },
) )
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown(): async def shutdown():
logger.warning("LNbits shutting down...") logger.warning("LNbits shutting down...")
enqueue_notification( enqueue_admin_notification(
NotificationType.server_start_stop, NotificationType.server_start_stop,
{ {
"message": "LNbits server shutting down...", "message": "LNbits server shutting down...",
@@ -171,8 +172,6 @@ def create_app() -> FastAPI:
name="library", name="library",
) )
g().base_url = f"http://{settings.host}:{settings.port}"
app.add_middleware( app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
) )
@@ -280,7 +279,7 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})") logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list( async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: Optional[bool] = True, include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
""" """
@@ -454,7 +453,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI): async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
try: try:
@@ -464,12 +463,12 @@ async def check_and_register_extensions(app: FastAPI):
logger.error(f"Could not load extension `{ext.code}`: {exc!s}") logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks(): def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data) create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages) create_permanent_task(wait_notification_messages)
create_permanent_task(check_pending_payments) create_permanent_task(run_interval(30 * 60, check_pending_payments))
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
+7 -7
View File
@@ -320,9 +320,8 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list(): for ext in await build_all_installed_extensions_list():
assert ( if not ext.meta or not ext.meta.installed_release:
ext.meta and ext.meta.installed_release raise ValueError(f"Extension {ext.id} has no installed_release")
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})") click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@@ -353,7 +352,7 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).", help="Admin user ID (must have permissions to install extensions).",
) )
@coro @coro
async def extensions_update( async def extensions_update( # noqa: C901
extension: Optional[str] = None, extension: Optional[str] = None,
all_extensions: Optional[bool] = False, all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None, repo_index: Optional[str] = None,
@@ -608,9 +607,10 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.") click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert ( if not installed_ext.meta or not installed_ext.meta.installed_release:
installed_ext.meta and installed_ext.meta.installed_release raise ValueError(
), "Cannot find previously installed release. Please uninstall first." "Cannot find previously installed release. Please uninstall first."
)
release = await _select_release(extension, repo_index, source_repo) release = await _select_release(extension, repo_index, source_repo)
if not release: if not release:
+6
View File
@@ -5,10 +5,13 @@ from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router from .views.tinyurl_api import tinyurl_router
@@ -34,10 +37,13 @@ def init_core_routers(app: FastAPI):
app.include_router(wallet_router) app.include_router(wallet_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]
+57 -66
View File
@@ -85,87 +85,78 @@ from .webpush import (
) )
__all__ = [ __all__ = [
# audit
"create_audit_entry",
# db_versions
"get_db_version",
"get_db_versions",
"update_migration_version",
"delete_dbversion",
# extensions
"create_installed_extension",
"create_user_extension",
"delete_installed_extension",
"drop_extension_db",
"get_installed_extension",
"get_installed_extensions",
"get_user_active_extensions_ids",
"get_user_extension",
"update_installed_extension",
"update_installed_extension_state",
"update_user_extension",
"get_user_extensions",
# payments
"DateTrunc", "DateTrunc",
"check_internal", "check_internal",
"create_payment",
"delete_expired_invoices",
"delete_wallet_payment",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_wallet_payment",
"is_internal_status_success",
"mark_webhook_sent",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user",
"reset_core_settings",
# tinyurl
"create_tinyurl",
"delete_tinyurl",
"get_tinyurl",
"get_tinyurl_by_url",
# users
"create_account", "create_account",
"create_admin_settings",
"create_audit_entry",
"create_installed_extension",
"create_payment",
"create_tinyurl",
"create_user_extension",
"create_wallet",
"create_webpush_subscription",
"delete_account", "delete_account",
"delete_accounts_no_wallets", "delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"delete_wallet_payment",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"drop_extension_db",
"force_delete_wallet",
"get_account", "get_account",
"get_account_by_email", "get_account_by_email",
"get_account_by_pubkey", "get_account_by_pubkey",
"get_account_by_username", "get_account_by_username",
"get_account_by_username_or_email", "get_account_by_username_or_email",
"get_accounts", "get_accounts",
"get_user", "get_admin_settings",
"get_user_from_account", "get_db_version",
"get_user_access_control_lists", "get_db_versions",
"update_account", "get_installed_extension",
# wallets "get_installed_extensions",
"create_wallet", "get_latest_payments_by_extension",
"delete_unused_wallets", "get_payment",
"delete_wallet", "get_payments",
"delete_wallet_by_id", "get_payments_history",
"force_delete_wallet", "get_payments_paginated",
"get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance", "get_total_balance",
"get_user",
"get_user_access_control_lists",
"get_user_active_extensions_ids",
"get_user_extension",
"get_user_extensions",
"get_user_from_account",
"get_wallet", "get_wallet",
"get_wallet_for_key", "get_wallet_for_key",
"get_wallet_payment",
"get_wallets", "get_wallets",
"remove_deleted_wallets",
"update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription", "get_webpush_subscription",
"get_webpush_subscriptions_for_user", "get_webpush_subscriptions_for_user",
"is_internal_status_success",
"mark_webhook_sent",
"remove_deleted_wallets",
"reset_core_settings",
"update_account",
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
"update_super_user",
"update_user_extension",
"update_wallet",
] ]
+9 -3
View File
@@ -30,10 +30,11 @@ async def delete_expired_audit_entries(
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
): ):
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE from audit DELETE from audit
WHERE delete_at < {db.timestamp_now} WHERE delete_at < {db.timestamp_now}
""", """, # noqa: S608
) )
@@ -48,13 +49,16 @@ async def get_count_stats(
filters = Filters() filters = Filters()
clause = filters.where() clause = filters.where()
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f""" query=f"""
SELECT {field} as field, count({field}) as total SELECT {field} as field, count({field}) as total
FROM audit FROM audit
{clause} {clause}
GROUP BY {field} GROUP BY {field}
ORDER BY {field} ORDER BY {field}
""", """, # noqa: S608
values=filters.values(), values=filters.values(),
model=AuditCountStat, model=AuditCountStat,
) )
@@ -70,13 +74,15 @@ async def get_long_duration_stats(
filters = Filters() filters = Filters()
clause = filters.where() clause = filters.where()
long_duration_paths = await (conn or db).fetchall( long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f""" query=f"""
SELECT path as field, max(duration) as total FROM audit SELECT path as field, max(duration) as total FROM audit
{clause} {clause}
GROUP BY path GROUP BY path
ORDER BY total DESC ORDER BY total DESC
LIMIT 5 LIMIT 5
""", """, # noqa: S608
values=filters.values(), values=filters.values(),
model=AuditCountStat, model=AuditCountStat,
) )
+7 -3
View File
@@ -50,7 +50,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
{"id": ext_id}, {"id": ext_id},
) )
# Check that 'ext_id' is a valid extension id and not a malicious string # Check that 'ext_id' is a valid extension id and not a malicious string
assert row, f"Extension '{ext_id}' db version cannot be found" if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id) is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db: if is_file_based_db:
@@ -78,10 +79,13 @@ async def get_installed_extensions(
active: Optional[bool] = None, active: Optional[bool] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
where = "WHERE active = :active" if active is not None else "" query = "SELECT * FROM installed_extensions"
if active is not None:
query += " WHERE active = :active"
values = {"active": active} if active is not None else {} values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall( all_extensions = await (conn or db).fetchall(
f"SELECT * FROM installed_extensions {where}", query,
values, values,
model=InstallableExtension, model=InstallableExtension,
) )
+42 -22
View File
@@ -1,5 +1,5 @@
from time import time from time import time
from typing import Any, Optional, Tuple from typing import Any, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db from lnbits.core.db import db
@@ -50,11 +50,13 @@ async def get_standalone_payment(
clause = f"({clause}) AND wallet_id = :wallet_id" clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE {clause} WHERE {clause}
ORDER BY amount LIMIT 1 ORDER BY amount LIMIT 1
""", """, # noqa: S608
values, values,
Payment, Payment,
) )
@@ -80,19 +82,25 @@ async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5 ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]: ) -> list[Payment]:
return await db.fetchall( return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE status = '{PaymentState.SUCCESS}' WHERE status = :status
AND extra LIKE :ext_name AND extra LIKE :ext_name
AND extra LIKE :ext_id AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)} ORDER BY time DESC LIMIT {int(limit)}
""", """, # noqa: S608
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"}, {
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
Payment, Payment,
) )
async def get_payments_paginated( async def get_payments_paginated( # noqa: C901
*, *,
wallet_id: Optional[str] = None, wallet_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
@@ -227,21 +235,23 @@ async def delete_expired_invoices(
# first we delete all invoices older than one month # first we delete all invoices older than one month
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0 WHERE status = :status AND amount > 0
AND time < {db.timestamp_placeholder("delta")} AND time < {db.timestamp_placeholder("delta")}
""", """, # noqa: S608
{"delta": int(time() - 2592000)}, {"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
) )
# then we delete all invoices whose expiry date is in the past # then we delete all invoices whose expiry date is in the past
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0 WHERE status = :status AND amount > 0
AND expiry < {db.timestamp_placeholder("now")} AND expiry < {db.timestamp_placeholder("now")}
""", """, # noqa: S608
{"now": int(time())}, {"status": f"{PaymentState.PENDING}", "now": int(time())},
) )
@@ -254,7 +264,8 @@ async def create_payment(
# we don't allow the creation of the same invoice twice # we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately # note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn) previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists" if previous_payment is not None:
raise ValueError("Payment already exists")
extra = data.extra or {} extra = data.extra or {}
payment = Payment( payment = Payment(
@@ -268,7 +279,7 @@ async def create_payment(
preimage=data.preimage, preimage=data.preimage,
expiry=data.expiry, expiry=data.expiry,
webhook=data.webhook, webhook=data.webhook,
fee=data.fee, fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
) )
@@ -321,16 +332,20 @@ async def get_payments_history(
) )
""" """
] ]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall( transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f""" f"""
SELECT {date_trunc} date, SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income, SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments FROM apipayments
{filters.where(where)} {clause}
GROUP BY date GROUP BY date
ORDER BY date DESC ORDER BY date DESC
""", """, # noqa: S608
filters.values(values), filters.values(values),
) )
if wallet_id: if wallet_id:
@@ -376,13 +391,16 @@ async def get_payment_count_stats(
clause = filters.where(extra_stmts) clause = filters.where(extra_stmts)
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f""" query=f"""
SELECT {field} as field, count(*) as total SELECT {field} as field, count(*) as total
FROM apipayments FROM apipayments
{clause} {clause}
GROUP BY {field} GROUP BY {field}
ORDER BY {field} ORDER BY {field}
""", """, # noqa: S608
values=filters.values(), values=filters.values(),
model=PaymentCountStat, model=PaymentCountStat,
) )
@@ -394,7 +412,7 @@ async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None, filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -468,6 +486,8 @@ async def get_wallets_stats(
clauses = filters.where(where_stmts) clauses = filters.where(where_stmts)
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
query=f""" query=f"""
SELECT apipayments.wallet_id, SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name, MAX(wallets.name) AS wallet_name,
@@ -479,7 +499,7 @@ async def get_wallets_stats(
{clauses} {clauses}
GROUP BY apipayments.wallet_id GROUP BY apipayments.wallet_id
ORDER BY payments_count ORDER BY payments_count
""", """, # noqa: S608
values=filters.values(), values=filters.values(),
model=PaymentWalletStats, model=PaymentWalletStats,
) )
@@ -504,11 +524,11 @@ async def check_internal(
otherwise None otherwise None
""" """
return await (conn or db).fetchone( return await (conn or db).fetchone(
f""" """
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0 WHERE payment_hash = :hash AND status = :status AND amount > 0
""", """,
{"hash": payment_hash}, {"status": f"{PaymentState.PENDING}", "hash": payment_hash},
Payment, Payment,
) )
+4 -2
View File
@@ -56,7 +56,8 @@ async def update_admin_settings(
async def update_super_user(super_user: str) -> SuperSettings: async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user) await set_settings_field("super_user", super_user)
settings = await get_super_settings() settings = await get_super_settings()
assert settings, "updated super_user settings could not be retrieved" if not settings:
raise ValueError("updated super_user settings could not be retrieved")
return settings return settings
@@ -86,7 +87,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
await set_settings_field(key, value) await set_settings_field(key, value)
settings = await get_super_settings() settings = await get_super_settings()
assert settings, "created admin settings could not be retrieved" if not settings:
raise ValueError("created admin settings could not be retrieved")
return settings return settings
+16 -9
View File
@@ -68,6 +68,7 @@ async def get_accounts(
accounts.username, accounts.username,
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -109,6 +110,7 @@ async def delete_accounts_no_wallets(
) -> None: ) -> None:
delta = int(time()) - time_delta delta = int(time()) - time_delta
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM accounts DELETE FROM accounts
WHERE NOT EXISTS ( WHERE NOT EXISTS (
@@ -117,7 +119,7 @@ async def delete_accounts_no_wallets(
(updated_at is null AND created_at < :delta) (updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")} OR updated_at < {db.timestamp_placeholder("delta")}
) )
""", """, # noqa: S608
{"delta": delta}, {"delta": delta},
) )
@@ -128,8 +130,8 @@ async def get_account_by_username(
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username", "SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username}, {"username": username.lower()},
Account, Account,
) )
@@ -138,8 +140,8 @@ async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]: ) -> Optional[Account]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey", "SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey}, {"pubkey": pubkey.lower()},
Account, Account,
) )
@@ -150,8 +152,8 @@ async def get_account_by_email(
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email", "SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email}, {"email": email.lower()},
Account, Account,
) )
@@ -160,8 +162,11 @@ async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]: ) -> Optional[Account]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :value or username = :value", """
{"value": username_or_email}, SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower()},
Account, Account,
) )
@@ -183,6 +188,7 @@ async def get_user_from_account(
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra, extra=account.extra,
created_at=account.created_at, created_at=account.created_at,
updated_at=account.updated_at, updated_at=account.updated_at,
@@ -190,6 +196,7 @@ async def get_user_from_account(
wallets=wallets, wallets=wallets,
admin=account.is_admin, admin=account.is_admin,
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
) )
+26 -21
View File
@@ -48,11 +48,12 @@ async def delete_wallet(
) -> None: ) -> None:
now = int(time()) now = int(time())
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')} SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user WHERE id = :wallet AND "user" = :user
""", """, # noqa: S608
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now}, {"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
) )
@@ -71,11 +72,12 @@ async def delete_wallet_by_id(
) -> Optional[int]: ) -> Optional[int]:
now = int(time()) now = int(time())
result = await (conn or db).execute( result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')} SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet WHERE id = :wallet
""", """, # noqa: S608
{"wallet": wallet_id, "now": now}, {"wallet": wallet_id, "now": now},
) )
return result.rowcount return result.rowcount
@@ -107,14 +109,16 @@ async def delete_unused_wallets(
async def get_wallet( async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]: ) -> Optional[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else "" query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone( return await (conn or db).fetchone(
f""" query,
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
{"wallet": wallet_id, "deleted": deleted}, {"wallet": wallet_id, "deleted": deleted},
Wallet, Wallet,
) )
@@ -123,14 +127,16 @@ async def get_wallet(
async def get_wallets( async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]: ) -> list[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else "" query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchall( return await (conn or db).fetchall(
f""" query,
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted}, {"user": user_id, "deleted": deleted},
Wallet, Wallet,
) )
@@ -162,12 +168,11 @@ async def get_wallets_paginated(
async def get_wallets_ids( async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]: ) -> list[str]:
where = "AND deleted = :deleted" if deleted is not None else "" query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall( result: list[dict] = await (conn or db).fetchall(
f""" query,
SELECT id FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted}, {"user": user_id, "deleted": deleted},
) )
return [row["id"] for row in result] return [row["id"] for row in result]
+2 -1
View File
@@ -37,7 +37,8 @@ async def create_webpush_subscription(
{"endpoint": endpoint, "user": user, "data": data, "host": host}, {"endpoint": endpoint, "user": user, "data": data, "host": host},
) )
subscription = await get_webpush_subscription(endpoint, user) subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved" if not subscription:
raise ValueError("Newly created webpush subscription couldn't be retrieved")
return subscription return subscription
+32 -8
View File
@@ -214,6 +214,7 @@ async def m007_set_invoice_expiries(db: Connection):
""" """
try: try:
result = await db.execute( result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
SELECT bolt11, checking_id SELECT bolt11, checking_id
FROM apipayments FROM apipayments
@@ -222,7 +223,7 @@ async def m007_set_invoice_expiries(db: Connection):
AND bolt11 IS NOT NULL AND bolt11 IS NOT NULL
AND expiry IS NULL AND expiry IS NULL
AND time < {db.timestamp_now} AND time < {db.timestamp_now}
""" """ # noqa: S608
) )
rows = result.mappings().all() rows = result.mappings().all()
if len(rows): if len(rows):
@@ -242,13 +243,15 @@ async def m007_set_invoice_expiries(db: Connection):
f" {invoice.payment_hash} to {expiration_date}" f" {invoice.payment_hash} to {expiration_date}"
) )
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')} UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0 WHERE checking_id = :checking_id AND amount > 0
""", """, # noqa: S608
{"expiry": expiration_date, "checking_id": checking_id}, {"expiry": expiration_date, "checking_id": checking_id},
) )
except Exception: except Exception as exc:
logger.debug(exc)
continue continue
except OperationalError: except OperationalError:
# this is necessary now because it may be the case that this migration will # this is necessary now because it may be the case that this migration will
@@ -371,7 +374,8 @@ async def m014_set_deleted_wallets(db: Connection):
"wallet": row.get("id"), "wallet": row.get("id"),
}, },
) )
except Exception: except Exception as exc:
logger.debug(exc)
continue continue
except OperationalError: except OperationalError:
# this is necessary now because it may be the case that this migration will # this is necessary now because it may be the case that this migration will
@@ -454,17 +458,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
# set all to now where they are null # set all to now where they are null
now = int(time()) now = int(time())
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')} UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """, # noqa: S608
{"now": now}, {"now": now},
) )
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')} UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """, # noqa: S608
{"now": now}, {"now": now},
) )
@@ -616,7 +622,12 @@ async def m027_update_apipayments_data(db: Connection):
logger.info(f"Updating {offset} to {offset+limit}") logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute( result = await db.execute(
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}" # Limit and Offset safe from SQL injection
# since they are integers and are not user input
f"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
) )
payments = result.mappings().all() payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}") logger.info(f"Payments count: {len(payments)}")
@@ -629,11 +640,12 @@ async def m027_update_apipayments_data(db: Connection):
tag = extra.get("tag") tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at") tsph = db.timestamp_placeholder("created_at")
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE apipayments UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph} SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id WHERE checking_id = :checking_id
""", """, # noqa: S608
{ {
"tag": tag, "tag": tag,
"created_at": created_at, "created_at": created_at,
@@ -711,3 +723,15 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
Adds icon and color columns to wallets. Adds icon and color columns to wallets.
""" """
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT") await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
+24 -29
View File
@@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import ( from .misc import (
BalanceDelta, BalanceDelta,
Callback, Callback,
@@ -9,6 +9,7 @@ from .misc import (
SimpleStatus, SimpleStatus,
) )
from .payments import ( from .payments import (
CancelInvoice,
CreateInvoice, CreateInvoice,
CreatePayment, CreatePayment,
DecodePayment, DecodePayment,
@@ -23,6 +24,7 @@ from .payments import (
PaymentsStatusCount, PaymentsStatusCount,
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -48,62 +50,55 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __all__ = [
# audit "AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"AuditEntry", "AuditEntry",
"AuditFilters", "AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta", "BalanceDelta",
"BaseWallet",
"Callback", "Callback",
"CancelInvoice",
"ConversionData", "ConversionData",
"CoreAppExtra", "CoreAppExtra",
"DbVersion",
"SimpleStatus",
# payments
"CreateInvoice", "CreateInvoice",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment", "CreatePayment",
"CreateUser",
"CreateWallet",
"CreateWebPushSubscription",
"DbVersion",
"DecodePayment", "DecodePayment",
"KeyType",
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice", "PayInvoice",
"Payment", "Payment",
"PaymentCountField", "PaymentCountField",
"PaymentCountStat", "PaymentCountStat",
"PaymentDailyStats", "PaymentDailyStats",
"PaymentsStatusCount",
"PaymentWalletStats",
"PaymentExtra", "PaymentExtra",
"PaymentFilters", "PaymentFilters",
"PaymentHistoryPoint", "PaymentHistoryPoint",
"PaymentState", "PaymentState",
# tinyurl "PaymentWalletStats",
"TinyURL", "PaymentsStatusCount",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"UserAcls",
"CreateUser",
"RegisterUser", "RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword", "ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
"UpdateUserPubkey", "UpdateUserPubkey",
"User", "User",
"UserAcls",
"UserExtra", "UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet", "Wallet",
"WalletTypeInfo", "WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription", "WebPushSubscription",
] ]
+4 -4
View File
@@ -211,7 +211,8 @@ class ExtensionRelease(BaseModel):
self, amount: int | None = None self, amount: int | None = None
) -> ReleasePaymentInfo | None: ) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info." if not url:
raise ValueError("Missing URL for payment info.")
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(url) resp = await client.get(url)
@@ -376,9 +377,8 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
try: try:
assert ( if not self.meta or not self.meta.installed_release:
self.meta and self.meta.installed_release raise ValueError("No installed release.")
), "installed_release is none."
self._restore_payment_info() self._restore_payment_info()
+6 -10
View File
@@ -1,20 +1,16 @@
from typing import Optional from typing import Optional
from lnurl import Lnurl, LnurlPayResponse
from pydantic import BaseModel from pydantic import BaseModel
class CreateLnurl(BaseModel): class CreateLnurlPayment(BaseModel):
description_hash: str res: LnurlPayResponse
callback: str
amount: int amount: int
comment: Optional[str] = None comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlAuth(BaseModel): class CreateLnurlWithdraw(BaseModel):
callback: str lnurl_w: Lnurl
class PayLnurlWData(BaseModel):
lnurl_w: str
+3
View File
@@ -2,6 +2,8 @@ from enum import Enum
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum): class NotificationType(Enum):
server_status = "server_status" server_status = "server_status"
@@ -18,6 +20,7 @@ class NotificationType(Enum):
class NotificationMessage(BaseModel): class NotificationMessage(BaseModel):
message_type: NotificationType message_type: NotificationType
values: dict values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = { NOTIFICATION_TEMPLATES = {
+96 -3
View File
@@ -5,9 +5,17 @@ from enum import Enum
from typing import Literal from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
@@ -60,6 +68,8 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
@@ -107,14 +117,23 @@ class Payment(BaseModel):
@property @property
def is_internal(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_") or self.checking_id.startswith(
"fiat_"
)
async def check_status(self) -> PaymentStatus: async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal: if self.is_internal:
if self.success: if self.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if self.failed: if self.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
@@ -123,6 +142,39 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
return status return status
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"] __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
@@ -201,11 +253,24 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640) memo: str | None = Query(None, max_length=640)
description_hash: str | None = None description_hash: str | None = None
unhashed_description: str | None = None unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None expiry: int | None = None
extra: dict | None = None extra: dict | None = None
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_callback: str | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit") @validator("unit")
@classmethod @classmethod
@@ -220,3 +285,31 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0 outgoing: int = 0
failed: int = 0 failed: int = 0
pending: int = 0 pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+1 -2
View File
@@ -1,5 +1,4 @@
"""Keycloak SSO Login Helper """Keycloak SSO Login Helper"""
"""
from typing import Optional from typing import Optional
+44 -8
View File
@@ -3,18 +3,32 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_pubkey,
is_valid_username,
)
from lnbits.settings import settings from lnbits.settings import settings
from .wallets import Wallet from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: bool | None = False email_verified: bool | None = False
first_name: str | None = None first_name: str | None = None
@@ -30,6 +44,8 @@ class UserExtra(BaseModel):
# how many wallets are shown in the user interface # how many wallets are shown in the user interface
visible_wallet_count: int | None = 10 visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
class EndpointAccess(BaseModel): class EndpointAccess(BaseModel):
path: str path: str
@@ -93,6 +109,7 @@ class UserAcls(BaseModel):
class Account(BaseModel): class Account(BaseModel):
id: str id: str
external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: str | None = None pubkey: str | None = None
@@ -104,24 +121,28 @@ class Account(BaseModel):
is_super_user: bool = Field(default=False, no_database=True) is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True) is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") salt = gensalt()
self.password_hash = pwd_context.hash(password) hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash return self.password_hash
def verify_password(self, password: str) -> bool: def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash""" """returns True if the password matches the hash"""
if not self.password_hash: if not self.password_hash:
return False return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") return checkpw(password.encode(), self.password_hash.encode())
return pwd_context.verify(password, self.password_hash)
def validate_fields(self): def validate_fields(self):
if self.username and not is_valid_username(self.username): if self.username and not is_valid_username(self.username):
@@ -130,6 +151,11 @@ class Account(BaseModel):
raise ValueError("Invalid email.") raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey): if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.") raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4) user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
@@ -143,7 +169,14 @@ class AccountOverview(Account):
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"] __search_fields__ = [
"user",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [ __sort_fields__ = [
"balance_msat", "balance_msat",
"email", "email",
@@ -157,6 +190,7 @@ class AccountFilters(FilterModel):
user: str | None = None user: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None wallet_id: str | None = None
@@ -167,10 +201,12 @@ class User(BaseModel):
email: str | None = None email: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = [] extensions: list[str] = []
wallets: list[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
@@ -207,13 +243,13 @@ class CreateUser(BaseModel):
password: str | None = Query(default=None, min_length=8, max_length=50) password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50) password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64) pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None extensions: list[str] | None = None
extra: UserExtra | None = None extra: UserExtra | None = None
class UpdateUser(BaseModel): class UpdateUser(BaseModel):
user_id: str user_id: str
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20) username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None extra: UserExtra | None = None
+1 -1
View File
@@ -7,11 +7,11 @@ from datetime import datetime, timezone
from enum import Enum from enum import Enum
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
+31 -24
View File
@@ -2,17 +2,24 @@ from .funding_source import (
get_balance_delta, get_balance_delta,
switch_to_voidwallet, switch_to_voidwallet,
) )
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
from .notifications import enqueue_notification, send_payment_notification from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice,
create_invoice, create_invoice,
create_payment_request,
create_wallet_invoice,
fee_reserve, fee_reserve,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
service_fee, service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
) )
@@ -30,36 +37,36 @@ from .users import (
from .websockets import websocket_manager, websocket_updater from .websockets import websocket_manager, websocket_updater
__all__ = [ __all__ = [
# funding source
"get_balance_delta",
"switch_to_voidwallet",
# lnurl
"redeem_lnurl_withdraw",
"perform_lnurlauth",
# notifications
"enqueue_notification",
"send_payment_notification",
# payments
"calculate_fiat_amounts", "calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"create_invoice",
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"service_fee",
"update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings", "check_webpush_settings",
"update_cached_settings", "create_fiat_invoice",
# users "create_invoice",
"check_admin_settings", "create_payment_request",
"create_user_account", "create_user_account",
"create_user_account_no_ckeck", "create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
"update_pending_payments",
"update_user_account", "update_user_account",
"update_user_extensions", "update_user_extensions",
# websockets "update_wallet_balance",
"websocket_manager", "websocket_manager",
"websocket_updater", "websocket_updater",
] ]
+7 -7
View File
@@ -26,10 +26,11 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta() ext_info.meta = ext_info.meta or ExtensionMeta()
if ext_info.meta.installed_release: if (
assert ( ext_info.meta.installed_release
ext_info.meta.installed_release.is_version_compatible and not ext_info.meta.installed_release.is_version_compatible
), "Incompatible extension version" ):
raise ValueError("Incompatible extension version")
installed_ext = await get_installed_extension(ext_info.id) installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
@@ -93,9 +94,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
old_module = importlib.import_module(ext.module_name) old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop" stop_fn_name = f"{ext_id}_stop"
assert hasattr( if not hasattr(old_module, stop_fn_name):
old_module, stop_fn_name raise ValueError(f"No stop function found for '{ext.module_name}'.")
), f"No stop function found for '{ext.module_name}'."
stop_fn = getattr(old_module, stop_fn_name) stop_fn = getattr(old_module, stop_fn_name)
if stop_fn: if stop_fn:
+194
View File
@@ -0,0 +1,194 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, get_standalone_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Optional[Connection] = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
try:
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
if payment.fee == 0:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_fee_wallet_id:
return
memo = (
f"Service fee for fiat payment of "
f"{abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_fee_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(payment.fee),
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_faucet_wallet_id:
return
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
)
memo = (
f"Faucet payment of {abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_faucet_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=-abs(payment.amount),
memo=memo,
extra=payment.extra,
)
await create_payment(
checking_id=f"internal_fiat_{fiat_provider_name}_"
f"faucet_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
def check_stripe_signature(
payload: bytes,
sig_header: Optional[str],
secret: Optional[str],
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)
+3 -3
View File
@@ -1,7 +1,7 @@
from loguru import logger from loguru import logger
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
f"Balance delta reached: {status.delta_sats} sats." f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}." f" Switch to void wallet: {use_voidwallet}."
) )
enqueue_notification( enqueue_admin_notification(
NotificationType.watchdog_check, NotificationType.watchdog_check,
{ {
"delta_sats": status.delta_sats, "delta_sats": status.delta_sats,
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
return return
if status.delta_sats != settings.latest_balance_delta_sats: if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_notification( enqueue_admin_notification(
NotificationType.balance_delta, NotificationType.balance_delta,
{ {
"delta_sats": status.delta_sats, "delta_sats": status.delta_sats,
+42 -147
View File
@@ -1,158 +1,53 @@
import asyncio from lnurl import (
import json LnurlPayActionResponse,
from io import BytesIO LnurlPayResponse,
from typing import Optional LnurlResponseException,
from urllib.parse import parse_qs, urlparse execute_pay_request,
handle,
import httpx
from fastapi import Depends
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
) )
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse from lnbits.core.models import CreateLnurlPayment
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from .payments import create_invoice
async def redeem_lnurl_withdraw( async def get_pr_from_lnurl(lnurl: str, amount_msat: int) -> str:
wallet_id: str, res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
lnurl_request: str, if not isinstance(res, LnurlPayResponse):
memo: Optional[str] = None, raise LnurlResponseException(
extra: Optional[dict] = None, "Invalid LNURL response. Expected LnurlPayResponse."
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
) )
except Exception: res2 = await execute_pay_request(
logger.warning( res,
f"failed to create invoice on redeem_lnurl_withdraw " msat=str(amount_msat),
f"from {lnurl}. params: {res}" user_agent=settings.user_agent,
timeout=10,
)
if not isinstance(res, LnurlPayActionResponse):
raise LnurlResponseException(
"Invalid LNURL pay response. Expected LnurlPayActionResponse."
) )
return None return res2.pr
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth( async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
callback: str, """
wallet: WalletTypeInfo = Depends(require_admin_key), Pay an LNURL payment request.
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0]) raises `LnurlResponseException` if pay request fails
"""
key = wallet.wallet.lnurlauth_key(cb.netloc) if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
def int_to_bytes_suitable_der(x: int) -> bytes: return await execute_pay_request(
"""for strict DER we need to encode the integer with some quirks""" data.res,
b = x.to_bytes((x.bit_length() + 7) // 8, "big") msat=str(amount_msat),
user_agent=settings.user_agent,
if len(b) == 0: timeout=10,
# ensure there's at least one byte when the int is zero )
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
+1 -2
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
from typing import Tuple
import httpx import httpx
from loguru import logger from loguru import logger
@@ -48,7 +47,7 @@ async def send_nostr_dm(
return dm_event.to_dict() return dm_event.to_dict()
async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]: async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]:
identifier, domain = identifier.split("@") identifier, domain = identifier.split("@")
if not identifier or not domain: if not identifier or not domain:
raise ValueError("Invalid NIP5 identifier") raise ValueError("Invalid NIP5 identifier")
+153 -75
View File
@@ -4,7 +4,7 @@ import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
from typing import Optional, Tuple from typing import Optional
import httpx import httpx
from loguru import logger from loguru import logger
@@ -16,23 +16,25 @@ from lnbits.core.crud import (
get_webpush_subscriptions_for_user, get_webpush_subscriptions_for_user,
mark_webhook_sent, mark_webhook_sent,
) )
from lnbits.core.crud.users import get_user
from lnbits.core.models import Payment, Wallet from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import ( from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
NotificationMessage, NotificationMessage,
NotificationType, NotificationType,
) )
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue = asyncio.Queue() notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
def enqueue_notification(message_type: NotificationType, values: dict) -> None: def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
if not is_message_type_enabled(message_type): if not _is_message_type_enabled(message_type):
return return
try: try:
notifications_queue.put_nowait( notifications_queue.put_nowait(
@@ -42,62 +44,125 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
logger.error(f"Error enqueuing notification: {e}") logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification(): def enqueue_user_notification(
notification_message: NotificationMessage = await notifications_queue.get() message_type: NotificationType, values: dict, user_notifications: UserNotifications
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message) message_type, text = _notification_message_to_text(notification_message)
await send_notification(text, message_type) user_notifications = notification_message.user_notifications
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: Optional[str] = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: Optional[str] = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
async def send_notification( async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str, message: str,
message_type: Optional[str] = None, message_type: Optional[str] = None,
) -> None: ) -> None:
try: try:
if settings.lnbits_telegram_notifications_enabled: if telegram_chat_id and settings.is_telegram_notifications_configured():
await send_telegram_notification(message) await send_telegram_notification(telegram_chat_id, message)
logger.debug(f"Sent telegram notification: {message_type}") logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}") logger.error(f"Error sending telegram notification {message_type}: {e}")
try: try:
if settings.lnbits_nostr_notifications_enabled: if nostr_identifiers and settings.is_nostr_notifications_configured():
await send_nostr_notification(message) await send_nostr_notifications(nostr_identifiers, message)
logger.debug(f"Sent nostr notification: {message_type}") logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}") logger.error(f"Error sending nostr notification {message_type}: {e}")
try: try:
if settings.lnbits_email_notifications_enabled: if email_addresses and settings.lnbits_email_notifications_enabled:
await send_email_notification(message) await send_email_notification(email_addresses, message)
logger.debug(f"Sent email notification: {message_type}") logger.debug(f"Sent email notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}") logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notification(message: str) -> dict: async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
for i in settings.lnbits_nostr_notifications_identifiers: success_sent: list[str] = []
for identifier in identifiers:
try: try:
identifier = await fetch_nip5_details(i) await send_nostr_notification(identifier, message)
user_pubkey = identifier[0] success_sent.append(identifier)
relays = identifier[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
except Exception as e: except Exception as e:
logger.warning(f"Error notifying identifier {i}: {e}") logger.warning(f"Error notifying identifier {identifier}: {e}")
return success_sent
return {"status": "ok"}
async def send_telegram_notification(message: str) -> dict: async def send_nostr_notification(identifier: str, message: str):
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
return await send_telegram_message( return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token, settings.lnbits_telegram_notifications_access_token,
settings.lnbits_telegram_notifications_chat_id, chat_id,
message, message,
) )
@@ -112,7 +177,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
async def send_email_notification( async def send_email_notification(
message: str, subject: str = "LNbits Notification" to_emails: list[str], message: str, subject: str = "LNbits Notification"
) -> dict: ) -> dict:
if not settings.lnbits_email_notifications_enabled: if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"} return {"status": "error", "message": "Email notifications are disabled"}
@@ -123,7 +188,7 @@ async def send_email_notification(
settings.lnbits_email_notifications_username, settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password, settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email, settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails, to_emails,
subject, subject,
message, message,
) )
@@ -163,41 +228,6 @@ async def send_email(
return True return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> Tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
""" """
Dispatches the webhook to the webhook url. Dispatches the webhook to the webhook url.
@@ -231,7 +261,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
except Exception as e: except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}") logger.error(f"Error sending websocket payment notification {e!s}")
try: try:
send_chat_payment_notification(wallet, payment) await send_chat_payment_notification(wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}") logger.error(f"Error sending chat payment notification {e!s}")
try: try:
@@ -268,7 +298,7 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
) )
def send_chat_payment_notification(wallet: Wallet, payment: Payment): async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat) amount_sats = abs(payment.sat)
values: dict = { values: dict = {
"wallet_id": wallet.id, "wallet_id": wallet.id,
@@ -283,10 +313,23 @@ def send_chat_payment_notification(wallet: Wallet, payment: Payment):
if payment.is_out: if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats: if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values) enqueue_admin_notification(NotificationType.outgoing_payment, values)
else: elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats: enqueue_admin_notification(NotificationType.incoming_payment, values)
enqueue_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
async def send_payment_push_notification(wallet: Wallet, payment: Payment): async def send_payment_push_notification(wallet: Wallet, payment: Payment):
@@ -330,3 +373,38 @@ async def send_push_notification(subscription, title, body, url=""):
f"failed sending push notification: " f"failed sending push notification: "
f"{e.response.text if e.response else e}" f"{e.response.text if e.response else e}"
) )
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
+352 -75
View File
@@ -6,20 +6,26 @@ from typing import Optional
from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import get_daily_stats from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@@ -61,7 +67,8 @@ async def pay_invoice(
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat) invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat if not invoice.amount_msat:
raise ValueError("Missig invoice amount.")
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat amount_msat = invoice.amount_msat
@@ -84,12 +91,138 @@ async def pay_invoice(
payment = await _pay_invoice(wallet.id, 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: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn) await _credit_service_fee_wallet(wallet, payment, new_conn)
return payment
async def create_payment_request(
wallet_id: str, invoice_data: CreateInvoice
) -> Payment:
"""
Create a lightning invoice or a fiat payment request.
"""
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
):
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.")
if not settings.is_fiat_provider_enabled(fiat_provider_name):
raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.",
)
if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.")
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
invoice_data.internal = True # use FakeWallet for fiat invoices
if not invoice_data.memo:
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
if not fiat_provider:
raise InvoiceError("No fiat provider found.", status="failed")
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
await update_payment(internal_payment, conn=conn)
raise ValueError(
f"Cannot create payment request for '{fiat_provider_name}'.",
)
internal_payment.fee = -abs(
service_fee_fiat(internal_payment.msat, fiat_provider_name)
)
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
return internal_payment
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = None
unhashed_description = None
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise ValueError(
"'description_hash' must be a valid hex string"
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise ValueError(
"'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet_id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
)
if data.lnurl_withdraw:
try:
check_callback_url(data.lnurl_withdraw.callback)
res = await lnurl_withdraw(
data.lnurl_withdraw,
payment.bolt11,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = res.reason
payment.status = "failed"
elif isinstance(res, LnurlSuccessResponse):
payment.extra["lnurl_response"] = True
payment.status = "success"
except Exception as exc:
payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
return payment return payment
@@ -106,6 +239,7 @@ async def create_invoice(
extra: Optional[dict] = None, extra: Optional[dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
payment_hash: str | None = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -139,38 +273,54 @@ async def create_invoice(
status="failed", status="failed",
) )
payment_response = await funding_source.create_invoice( if payment_hash:
amount=amount_sat, try:
memo=invoice_memo, invoice_response = await funding_source.create_hold_invoice(
description_hash=description_hash, amount=amount_sat,
unhashed_description=unhashed_description, memo=invoice_memo,
expiry=expiry or settings.lightning_invoice_expiry, payment_hash=payment_hash,
) description_hash=description_hash,
)
extra["hold_invoice"] = True
except UnsupportedError as exc:
raise InvoiceError(
"Hold invoices are not supported by the funding source.",
status="failed",
) from exc
else:
invoice_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 ( if (
not payment_response.ok not invoice_response.ok
or not payment_response.payment_request or not invoice_response.payment_request
or not payment_response.checking_id or not invoice_response.checking_id
): ):
raise InvoiceError( raise InvoiceError(
message=payment_response.error_message or "unexpected backend error.", message=invoice_response.error_message or "unexpected backend error.",
status="pending", status="pending",
) )
invoice = bolt11_decode(payment_response.payment_request) invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet_id, wallet_id=wallet_id,
bolt11=payment_response.payment_request, bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
preimage=payment_response.preimage, preimage=invoice_response.preimage,
amount_msat=amount_sat * 1000, amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0,
) )
payment = await create_payment( payment = await create_payment(
checking_id=payment_response.checking_id, checking_id=invoice_response.checking_id,
data=create_payment_model, data=create_payment_model,
conn=conn, conn=conn,
) )
@@ -188,17 +338,45 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment) await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> bool: async def update_pending_payment(payment: Payment) -> Payment:
status = await payment.check_status() status = await payment.check_status()
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment) await update_payment(payment)
return True elif status.success:
if status.success: payment = await update_payment_success_status(payment, status)
payment.status = PaymentState.SUCCESS return payment
await update_payment(payment)
return True
return False async def check_pending_payments():
"""
check_pending_payments is called during startup to check for pending payments with
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
return
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
exclude_uncheckable=True,
)
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
payment = await update_pending_payment(payment)
prefix = f"payment ({i+1} / {count})"
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int: def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
@@ -226,6 +404,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0 return 0
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
"""
Calculate the service fee for a fiat provider based on the amount in msat.
Return the fee in msat.
"""
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return 0
amount_msat = abs(amount_msat)
fee_max = limits.service_max_fee_sats * 1000
if not limits.service_fee_wallet_id:
return 0
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
async def update_wallet_balance( async def update_wallet_balance(
wallet: Wallet, wallet: Wallet,
amount: int, amount: int,
@@ -284,10 +482,7 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
# notify receiver asynchronously await internal_invoice_queue_put(payment.checking_id)
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def check_wallet_limits( async def check_wallet_limits(
@@ -571,57 +766,55 @@ async def _pay_external_invoice(
) )
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
task = create_task( task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
# make sure a hold invoice or deferred payment is not blocking the server # make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try: try:
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds) payment_response = await asyncio.wait_for(task, timeout=wait_time)
payment_response = await asyncio.wait_for(task, wait_time)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# return pending payment on timeout # return pending payment on timeout
logger.debug(f"payment timeout, {checking_id} is still pending") logger.debug(
f"payment timeout after {wait_time}s, {checking_id} is still pending"
)
return payment return payment
if payment_response.checking_id and payment_response.checking_id != checking_id: # payment failed
logger.warning( if (
f"backend sent unexpected checking_id (expected: {checking_id} got:" payment_response.checking_id is None
f" {payment_response.checking_id})" or payment_response.ok is False
) or payment_response.checking_id != checking_id
if payment_response.checking_id and payment_response.ok is not False: ):
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
raise PaymentError( message = payment_response.error_message or "without an error message."
f"Payment failed: {payment_response.error_message}" raise PaymentError(f"Payment failed: {message}", status="failed")
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
if payment_response.success:
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
return payment
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn)
return payment return payment
@@ -655,8 +848,7 @@ async def _verify_external_payment(
if status.success: if status.success:
# payment was successful on the fundingsource # payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS await update_payment_success_status(payment, status, conn=conn)
await update_payment(payment, conn=conn)
raise PaymentError( raise PaymentError(
"Failed payment was already paid on the fundingsource.", "Failed payment was already paid on the fundingsource.",
status="success", status="success",
@@ -709,12 +901,16 @@ def _validate_payment_request(
async def _credit_service_fee_wallet( async def _credit_service_fee_wallet(
payment: Payment, memo: str, conn: Optional[Connection] = None wallet: Wallet, payment: Payment, conn: Optional[Connection] = None
): ):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal) service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat: if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return return
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11, bolt11=payment.bolt11,
@@ -728,3 +924,84 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
) )
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
)
min_amount_sat = limits.service_min_amount_sats
if min_amount_sat and (amount_sat < min_amount_sat):
raise ValueError(
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
)
max_amount_sats = limits.service_max_amount_sats
if max_amount_sats and (amount_sat > max_amount_sats):
raise ValueError(
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
)
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
if not limits.service_fee_wallet_id:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
)
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
if not fees_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
)
if limits.service_faucet_wallet_id:
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
)
if faucet_wallet.balance < amount_sat:
raise ValueError(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response
+2 -1
View File
@@ -19,7 +19,8 @@ async def check_webpush_settings():
vapid = Vapid() vapid = Vapid()
vapid.generate_keys() vapid.generate_keys()
privkey = vapid.private_pem() privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist" if not vapid.public_key:
raise ValueError("VAPID public key does not exist")
pubkey = b64urlencode( pubkey = b64urlencode(
vapid.public_key.public_bytes( vapid.public_key.public_bytes(
serialization.Encoding.X962, serialization.Encoding.X962,
+2 -1
View File
@@ -80,7 +80,8 @@ async def create_user_account_no_ckeck(
logger.error(f"Error enabeling default extension {ext_id}: {e}") logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account) user = await get_user_from_account(account)
assert user, "Cannot find user for account." if not user:
raise ValueError("Cannot find user for account.")
return user return user
+15 -14
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import traceback import traceback
from typing import Callable, Coroutine from collections.abc import Coroutine
from typing import Callable
from loguru import logger from loguru import logger
@@ -12,7 +13,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models import AuditEntry from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import ( from lnbits.core.services.funding_source import (
@@ -21,7 +22,7 @@ from lnbits.core.services.funding_source import (
get_balance_delta, get_balance_delta,
) )
from lnbits.core.services.notifications import ( from lnbits.core.services.notifications import (
enqueue_notification, enqueue_admin_notification,
process_next_notification, process_next_notification,
send_payment_notification, send_payment_notification,
) )
@@ -30,10 +31,10 @@ from lnbits.settings import settings
from lnbits.tasks import create_unique_task from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue = asyncio.Queue() audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def run_by_the_minute_tasks(): async def run_by_the_minute_tasks() -> None:
minute_counter = 0 minute_counter = 0
while settings.lnbits_running: while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60 status_minutes = settings.lnbits_notification_server_status_hours * 60
@@ -68,7 +69,7 @@ async def run_by_the_minute_tasks():
await asyncio.sleep(60) await asyncio.sleep(60)
async def _notify_server_status(): async def _notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0)) accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count() wallets_count = await get_wallets_count()
payments = await get_payments_status_count() payments = await get_payments_status_count()
@@ -86,10 +87,10 @@ async def _notify_server_status():
"lnbits_balance_sats": status.lnbits_balance_sats, "lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats, "node_balance_sats": status.node_balance_sats,
} }
enqueue_notification(NotificationType.server_status, values) enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
""" """
This worker dispatches events to all extensions and dispatches webhooks. This worker dispatches events to all extensions and dispatches webhooks.
""" """
@@ -102,13 +103,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
await send_payment_notification(wallet, payment) await send_payment_notification(wallet, payment)
async def wait_for_audit_data(): async def wait_for_audit_data() -> None:
""" """
Waits for audit entries to be pushed to the queue. Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB. Then it inserts the entries into the DB.
""" """
while settings.lnbits_running: while settings.lnbits_running:
data: AuditEntry = await audit_queue.get() data = await audit_queue.get()
try: try:
await create_audit_entry(data) await create_audit_entry(data)
except Exception as ex: except Exception as ex:
@@ -116,17 +117,17 @@ async def wait_for_audit_data():
await asyncio.sleep(3) await asyncio.sleep(3)
async def wait_notification_messages(): async def wait_notification_messages() -> None:
while settings.lnbits_running: while settings.lnbits_running:
try: try:
await process_next_notification() await process_next_notification()
except Exception as ex: except Exception as ex:
logger.log("error", ex) logger.warning("Payment notification error", ex)
await asyncio.sleep(3) await asyncio.sleep(3)
async def purge_audit_data(): async def purge_audit_data() -> None:
""" """
Remove audit entries which have passed their retention period. Remove audit entries which have passed their retention period.
""" """
@@ -140,7 +141,7 @@ async def purge_audit_data():
await asyncio.sleep(60 * 60) await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data(): async def collect_exchange_rates_data() -> None:
""" """
Collect exchange rates data. Used for monitoring only. Collect exchange rates data. Used for monitoring only.
""" """
@@ -0,0 +1,287 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.async_payment_failed</code></li>
<li><code>checkout.session.async_payment_succeeded</code></li>
<li><code>checkout.session.completed</code></li>
<li><code>checkout.session.expired</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>
+10 -8
View File
@@ -145,7 +145,9 @@
type="text" type="text"
:label="$t('ad_slots_label')" :label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')" :hint="$t('ad_slots_hint')"
> ><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input> </q-input>
<q-toggle <q-toggle
v-model="formData.lnbits_ad_space_enabled" v-model="formData.lnbits_ad_space_enabled"
@@ -154,9 +156,9 @@
<br /> <br />
</div> </div>
</div> </div>
<p><span v-text="$t('ui_default_theme')"></span></p> <div class="row q-col-gutter-md q-my-md">
<div class="row q-col-gutter-md"> <div class="col-12" v-text="$t('ui_default_theme')"></div>
<div class="col"> <div class="col-12 col-sm-6 col-lg-3">
<q-select <q-select
v-model="formData.lnbits_default_border" v-model="formData.lnbits_default_border"
:options="globalBorderOptions" :options="globalBorderOptions"
@@ -165,7 +167,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col"> <div class="col-12 col-sm-6 col-lg-3">
<q-select <q-select
v-model="formData.lnbits_default_theme" v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options" :options="lnbits_theme_options"
@@ -174,7 +176,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col"> <div class="col-12 col-sm-6 col-lg-3">
<q-select <q-select
v-model="formData.lnbits_default_reaction" v-model="formData.lnbits_default_reaction"
:options="reactionOptions" :options="reactionOptions"
@@ -183,7 +185,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col"> <div class="col-12 col-sm-6 col-lg-3">
<q-input <q-input
type="text" type="text"
v-model="formData.lnbits_default_bgimage" v-model="formData.lnbits_default_bgimage"
@@ -193,7 +195,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col"> <div class="col-12 col-sm-6 col-lg-3">
<q-toggle <q-toggle
type="bool" type="bool"
v-model="formData.lnbits_default_gradient" v-model="formData.lnbits_default_gradient"
+9 -1
View File
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip ><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab> ></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab <q-tab
name="users" name="users"
icon="group" icon="group"
@@ -183,7 +190,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
> >
{% include "admin/_tab_funding.html" %} {% include {% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" "admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %} {% include %} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include "admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include "admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html" "admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
+195 -72
View File
@@ -7,38 +7,71 @@
<div class="row q-col-gutter-md q-mb-md"> <div class="row q-col-gutter-md q-mb-md">
<div class="col-12"> <div class="col-12">
<q-card> <q-card>
<div> <div class="q-pa-sm">
<div class="q-gutter-y-md"> <div class="row items-center justify-between q-gutter-xs">
<q-tabs v-model="tab" align="left"> <div class="col">
<q-tab <q-btn @click="updateAccount" unelevated color="primary">
name="user" <span v-text="$t('update_account')"></span>
:label="$t('account_settings')" </q-btn>
@update="val => tab = val.name" </div>
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div> </div>
</div> </div>
</q-card> </q-card>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md q-mb-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md"> <div class="col-12">
<q-card> <q-card>
<q-card-section> <q-splitter>
<div class="row"> <template v-slot:before>
<div class="col"> <q-tabs v-model="tab" vertical active-color="primary">
<q-tab-panels v-model="tab"> <q-tab
name="user"
icon="person"
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('account_settings')"></span
></q-tooltip>
</q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span
></q-tooltip>
</q-tab>
<q-tab
name="theme"
icon="palette"
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('look_and_feel')"></span
></q-tooltip>
</q-tab>
<q-tab
name="api_acls"
icon="lock"
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('access_control_list')"></span
></q-tooltip>
</q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-scroll-area style="height: 80vh">
<q-tab-panels v-if="user" v-model="tab">
<q-tab-panel name="user"> <q-tab-panel name="user">
<div v-if="credentialsData.show"> <div v-if="credentialsData.show">
<q-card-section> <q-card-section>
@@ -272,19 +305,27 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
<q-input
v-model="user.external_id"
:label="$t('external_id')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input <q-input
v-model="user.extra.picture" v-model="user.extra.picture"
:label="$t('picture')" :label="$t('picture')"
filled filled
dense
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn <q-btn
@click="showUpdateCredentials()" @click="showUpdateCredentials()"
:label="$t('change_password')" :label="$t('change_password')"
@@ -357,89 +398,89 @@
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn <q-btn
v-if="g.allowedThemes.includes('classic')" v-if="allowedThemes.includes('classic')"
dense dense
flat flat
@click="themeChoiceFunc('classic')" @click="changeTheme('classic')"
icon="circle" icon="circle"
color="deep-purple" color="deep-purple"
size="md" size="md"
><q-tooltip>classic</q-tooltip> ><q-tooltip>classic</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('bitcoin')" v-if="allowedThemes.includes('bitcoin')"
dense dense
flat flat
@click="themeChoiceFunc('bitcoin')" @click="changeTheme('bitcoin')"
icon="circle" icon="circle"
color="deep-orange" color="deep-orange"
size="md" size="md"
><q-tooltip>bitcoin</q-tooltip> ><q-tooltip>bitcoin</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('mint')" v-if="allowedThemes.includes('mint')"
dense dense
flat flat
@click="themeChoiceFunc('mint')" @click="changeTheme('mint')"
icon="circle" icon="circle"
color="green" color="green"
size="md" size="md"
><q-tooltip>mint</q-tooltip> </q-btn ><q-tooltip>mint</q-tooltip> </q-btn
><q-btn ><q-btn
v-if="g.allowedThemes.includes('autumn')" v-if="allowedThemes.includes('autumn')"
dense dense
flat flat
@click="themeChoiceFunc('autumn')" @click="changeTheme('autumn')"
icon="circle" icon="circle"
color="brown" color="brown"
size="md" size="md"
><q-tooltip>autumn</q-tooltip> ><q-tooltip>autumn</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('monochrome')" v-if="allowedThemes.includes('monochrome')"
dense dense
flat flat
@click="themeChoiceFunc('monochrome')" @click="changeTheme('monochrome')"
icon="circle" icon="circle"
color="grey" color="grey"
size="md" size="md"
><q-tooltip>monochrome</q-tooltip> ><q-tooltip>monochrome</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('salvador')" v-if="allowedThemes.includes('salvador')"
dense dense
flat flat
@click="themeChoiceFunc('salvador')" @click="changeTheme('salvador')"
icon="circle" icon="circle"
color="blue-10" color="blue-10"
size="md" size="md"
><q-tooltip>elSalvador</q-tooltip> ><q-tooltip>elSalvador</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('freedom')" v-if="allowedThemes.includes('freedom')"
dense dense
flat flat
@click="themeChoiceFunc('freedom')" @click="changeTheme('freedom')"
icon="circle" icon="circle"
color="pink-13" color="pink-13"
size="md" size="md"
><q-tooltip>freedom</q-tooltip> ><q-tooltip>freedom</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('cyber')" v-if="allowedThemes.includes('cyber')"
dense dense
flat flat
@click="themeChoiceFunc('cyber')" @click="changeTheme('cyber')"
icon="circle" icon="circle"
color="light-green-9" color="light-green-9"
size="md" size="md"
><q-tooltip>cyber</q-tooltip> ><q-tooltip>cyber</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('flamingo')" v-if="allowedThemes.includes('flamingo')"
dense dense
flat flat
@click="themeChoiceFunc('flamingo')" @click="changeTheme('flamingo')"
icon="circle" icon="circle"
color="pink-3" color="pink-3"
size="md" size="md"
@@ -453,9 +494,9 @@
</div> </div>
<div class="col-8"> <div class="col-8">
<q-input <q-input
v-model="bgimageSelection" v-model="bgimageChoice"
:label="$t('background_image')" :label="$t('background_image')"
@update:model-value="bgimageChoiceFunc" @update:model-value="applyBackgroundImage"
> >
<q-tooltip <q-tooltip
><span v-text="$t('background_image')"></span ><span v-text="$t('background_image')"></span
@@ -468,19 +509,18 @@
<span v-text="$t('gradient_background')"></span> <span v-text="$t('gradient_background')"></span>
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn <q-toggle
dense dense
flat flat
round round
@click="toggleGradient"
icon="gradient" icon="gradient"
size="sm"
v-model="gradientChoice" v-model="gradientChoice"
@update:model-value="applyGradient"
> >
<q-tooltip <q-tooltip
><span v-text="$t('toggle_gradient')"></span ><span v-text="$t('toggle_gradient')"></span
></q-tooltip> ></q-tooltip>
</q-btn> </q-toggle>
</div> </div>
</div> </div>
@@ -489,10 +529,11 @@
<span v-text="$t('toggle_darkmode')"></span> <span v-text="$t('toggle_darkmode')"></span>
</div> </div>
<div class="col-8"> <div class="col-8">
<q-btn <q-toggle
dense dense
flat flat
round round
v-model="darkChoice"
@click="toggleDarkMode" @click="toggleDarkMode"
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'" :icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
size="sm" size="sm"
@@ -500,7 +541,7 @@
<q-tooltip <q-tooltip
><span v-text="$t('toggle_darkmode')"></span ><span v-text="$t('toggle_darkmode')"></span
></q-tooltip> ></q-tooltip>
</q-btn> </q-toggle>
</div> </div>
</div> </div>
<div class="row q-mb-md"> <div class="row q-mb-md">
@@ -509,7 +550,7 @@
</div> </div>
<div class="col-8"> <div class="col-8">
<q-select <q-select
v-model="borderSelection" v-model="borderChoice"
:options="borderOptions" :options="borderOptions"
label="Borders" label="Borders"
@update:model-value="applyBorder" @update:model-value="applyBorder"
@@ -537,7 +578,7 @@
</div> </div>
<div class="col-8"> <div class="col-8">
<q-select <q-select
v-model="reactionSelection" v-model="reactionChoice"
:options="reactionOptions" :options="reactionOptions"
label="Reactions" label="Reactions"
@update:model-value="reactionChoiceFunc" @update:model-value="reactionChoiceFunc"
@@ -548,12 +589,101 @@
</q-select> </q-select>
</div> </div>
</div> </div>
<q-separator></q-separator> </q-tab-panel>
<div class="row q-mb-md q-mt-md"> <q-tab-panel name="notifications">
<q-btn @click="updateAccount" unelevated color="primary"> <q-card-section>
<span v-text="$t('update_account')"></span> <div class="row q-mb-md">
</q-btn> <div class="col-4">
</div> <span
v-text="$t('notifications_nostr_identifier')"
></span>
{%if not nostr_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.nostr_identifier"
:hint="$t('notifications_nostr_identifier_desc')"
>
</q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications_chat_id')"></span>
{%if not telegram_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.telegram_chat_id"
:hint="$t('notifications_chat_id_desc')"
/>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_outgoing_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.outgoing_payments_sats"
:hint="$t('notification_outgoing_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_incoming_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.incoming_payments_sats"
:hint="$t('notification_incoming_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('exclude_wallets')"></span>
</div>
<div class="col-8">
<q-select
filled
dense
emit-value
map-options
multiple
v-model="user.extra.notifications.excluded_wallets"
:options="g.user.walletOptions"
:label="$t('exclude_wallets')"
:hint="$t('notifications_excluded_wallets_desc')"
class="q-mt-sm"
>
</q-select>
</div>
</div>
</q-card-section>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="api_acls"> <q-tab-panel name="api_acls">
<div class="row q-mb-md"> <div class="row q-mb-md">
@@ -780,16 +910,9 @@
</q-card-section> </q-card-section>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
</div> </q-scroll-area>
</div> </template>
</q-card-section> </q-splitter>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-card> </q-card>
</div> </div>
</div> </div>
+9 -7
View File
@@ -166,11 +166,12 @@
v-if="authAction === 'login' && allowedRegister" v-if="authAction === 'login' && allowedRegister"
class="q-mb-none" class="q-mb-none"
> >
Not registered? Create an Not registered?
<span <a
href="#"
class="text-secondary cursor-pointer" class="text-secondary cursor-pointer"
@click="showRegister('username-password')" @click.prevent="showRegister('username-password')"
>Account</span >Create an Account</a
> >
</p> </p>
<p <p
@@ -182,11 +183,12 @@
<p v-else-if="authAction === 'register'" class="q-mb-none"> <p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span> <span v-text="$t('existing_account_question')"></span>
<span <a
href="#"
class="text-secondary cursor-pointer q-ml-sm" class="text-secondary cursor-pointer q-ml-sm"
@click="showLogin('username-password')" @click.prevent="showLogin('username-password')"
v-text="$t('login')" v-text="$t('login')"
></span> ></a>
</p> </p>
</div> </div>
</username-password> </username-password>
+187 -28
View File
@@ -211,7 +211,12 @@
</q-card> </q-card>
<div id="hiddenQrCodeContainer" style="display: none"> <div id="hiddenQrCodeContainer" style="display: none">
<lnbits-qrcode <lnbits-qrcode
:value="'lightning:' + this.receive.paymentReq" v-if="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
></lnbits-qrcode>
<lnbits-qrcode
v-else
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
></lnbits-qrcode> ></lnbits-qrcode>
</div> </div>
</div> </div>
@@ -619,14 +624,30 @@
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
{% else %} {% else %}
<q-select <div class="row">
filled <div class="col-10">
dense <q-select
v-model="receive.unit" filled
type="text" dense
:label="$t('unit')" v-model="receive.unit"
:options="receive.units" type="text"
></q-select> :label="$t('unit')"
:options="receive.units"
></q-select>
</div>
<div class="col-2">
<q-btn
v-if="g.fiatTracking"
@click="swapBalancePriority"
class="float-right"
color="primary"
flat
dense
icon="swap_vert"
></q-btn>
</div>
</div>
<q-input <q-input
ref="setAmount" ref="setAmount"
filled filled
@@ -640,13 +661,97 @@
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
{% endif %} {% endif %}
<q-input
v-if="has_holdinvoice"
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input <q-input
filled filled
dense dense
v-model.trim="receive.data.memo" type="textarea"
rows="2"
v-model="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
>
<template v-if="receive.data.internalMemo === null" v-slot:append>
<q-icon
name="add_comment"
@click.stop.prevent="receive.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip>
</template>
</q-input>
<q-input
v-if="receive.data.internalMemo !== null"
autogrow
filled
dense
v-model="receive.data.internalMemo"
class="q-mb-lg"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_receive')"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="receive.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input> ></q-input>
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
<q-list bordered dense class="rounded-borders">
<q-item-label dense header>
<span v-text="$t('select_payment_provider')"></span>
</q-item-label>
<q-separator></q-separator>
<q-item
:active="!receive.fiatProvider"
@click="receive.fiatProvider = ''"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar square>
<img
:src="'{{ static_url_for('static', 'images/logos/lnbits.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section>
<span
v-text="$t('pay_with', {provider: 'Lightning Network'})"
></span>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
:active="receive.fiatProvider === 'stripe'"
@click="receive.fiatProvider = 'stripe'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@@ -680,7 +785,14 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq"> <a
v-if="receive.fiatPaymentReq"
:href="receive.fiatPaymentReq"
target="_blank"
>
<div v-html="invoiceQrCode"></div>
</a>
<a v-else :href="'lightning:' + receive.paymentReq">
<div v-html="invoiceQrCode"></div> <div v-html="invoiceQrCode"></div>
</a> </a>
</div> </div>
@@ -691,25 +803,27 @@
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm"> <h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span> <span v-text="formattedSatAmount"></span>
</h5> </h5>
<q-chip v-if="hasNfc" outline square color="positive"> <div v-if="!receive.fiatPaymentReq">
<q-avatar <q-chip v-if="hasNfc" outline square color="positive">
icon="nfc" <q-avatar
color="positive" icon="nfc"
text-color="white" color="positive"
></q-avatar> text-color="white"
<span v-text="$t('nfc_supported')"></span> ></q-avatar>
</q-chip> <span v-text="$t('nfc_supported')"></span>
<span </q-chip>
v-else <span
class="text-caption text-grey" v-else
v-text="$t('nfc_not_supported')" class="text-caption text-grey"
></span> v-text="$t('nfc_not_supported')"
></span>
</div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(receive.paymentReq)" @click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')" :label="$t('copy_invoice')"
></q-btn> ></q-btn>
<q-btn <q-btn
@@ -768,6 +882,21 @@
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<h6 class="text-center" v-text="parse.invoice.description"></h6> <h6 class="text-center" v-text="parse.invoice.description"></h6>
<q-input
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class="q-mb-lg"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-if="parse.data.internalMemo" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer" /></template
></q-input>
<q-list separator bordered dense class="q-mb-md"> <q-list separator bordered dense class="q-mb-md">
<q-expansion-item expand-separator icon="info" label="Details"> <q-expansion-item expand-separator icon="info" label="Details">
<q-list separator> <q-list separator>
@@ -961,7 +1090,7 @@
</p> </p>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col q-mb-lg">
<q-select <q-select
filled filled
dense dense
@@ -995,9 +1124,39 @@
filled filled
dense dense
v-model="parse.data.comment" v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'" :type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
label="Comment (optional)" label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed" :maxlength="parse.lnurlpay.commentAllowed"
><template
v-if="parse.data.internalMemo === null"
v-slot:append
>
<q-icon
name="add_comment"
@click.stop.prevent="parse.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip> </template
></q-input>
<br />
<q-input
v-if="parse.data.internalMemo !== null"
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class=""
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input> ></q-input>
</div> </div>
</div> </div>
@@ -147,6 +147,14 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input <q-input
v-model="activeUser.data.extra.picture" v-model="activeUser.data.extra.picture"
:label="$t('picture')" :label="$t('picture')"
+12 -5
View File
@@ -16,7 +16,7 @@ from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import ( from lnbits.core.services import (
enqueue_notification, enqueue_admin_notification,
get_balance_delta, get_balance_delta,
update_cached_settings, update_cached_settings,
) )
@@ -65,7 +65,9 @@ async def api_monitor():
) )
async def api_test_email(): async def api_test_email():
return await send_email_notification( return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email" settings.lnbits_email_notifications_to_emails,
"This is a LNbits test email.",
"LNbits Test Email",
) )
@@ -82,10 +84,13 @@ async def api_get_settings(
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)): async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
enqueue_notification(NotificationType.settings_update, {"username": user.username}) enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user) admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found." if not admin_settings:
raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict()) update_cached_settings(admin_settings.dict())
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
return {"status": "Success"} return {"status": "Success"}
@@ -112,7 +117,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK) @admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(user: User = Depends(check_super_user)) -> None: async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_notification(NotificationType.settings_update, {"username": user.username}) enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await reset_core_settings() await reset_core_settings()
server_restart.set() server_restart.set()
+3 -156
View File
@@ -1,36 +1,20 @@
import hashlib
import json
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from time import time from time import time
from typing import Any from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode import pyqrcode
from fastapi import ( from fastapi import APIRouter, Depends
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet, BaseWallet,
ConversionData, ConversionData,
CreateLnurlAuth,
CreateWallet, CreateWallet,
User, User,
Wallet, Wallet,
) )
from lnbits.decorators import ( from lnbits.decorators import check_user_exists
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
@@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth from ..services import create_user_account
api_router = APIRouter(tags=["Core"]) api_router = APIRouter(tags=["Core"])
@@ -92,143 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return user.wallets[0] return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
assert lnurlauth_key.verifying_key
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
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",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get( @api_router.get(
"/api/v1/rate/history", "/api/v1/rate/history",
dependencies=[Depends(check_user_exists)], dependencies=[Depends(check_user_exists)],
+54 -41
View File
@@ -191,12 +191,14 @@ async def api_create_user_api_token(
data: ApiTokenRequest, data: ApiTokenRequest,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> ApiTokenResponse: ) -> ApiTokenResponse:
assert data.expiration_time_minutes > 0, "Expiration time must be in the future." if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id) account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
assert account.username, "Username must be configured." if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
@@ -223,7 +225,8 @@ async def api_delete_user_api_token(
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
assert account.username, "Username must be configured." if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
@@ -318,7 +321,7 @@ async def update_pubkey(
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]: ) -> Optional[User]:
if data.user_id != user.id: if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if ( if (
@@ -326,7 +329,7 @@ async def update_pubkey(
and data.pubkey != user.pubkey and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey) and await get_account_by_pubkey(data.pubkey)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.") raise ValueError("Public key already in use.")
account = await get_account(user.id) account = await get_account(user.id)
if not account: if not account:
@@ -344,7 +347,8 @@ async def update_password(
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]: ) -> Optional[User]:
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
assert data.user_id == user.id, "Invalid user ID." if data.user_id != user.id:
raise ValueError("Invalid user ID.")
if ( if (
data.username data.username
and user.username != data.username and user.username != data.username
@@ -353,12 +357,15 @@ async def update_password(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id) account = await get_account(user.id)
assert account, "Account not found." if not account:
raise ValueError("Account not found.")
# old accounts do not have a password # old accounts do not have a password
if account.password_hash: if account.password_hash:
assert data.password_old, "Missing old password." if not data.password_old:
assert account.verify_password(data.password_old), "Invalid old password." raise ValueError("Missing old password.")
if not account.verify_password(data.password_old):
raise ValueError("Invalid old password.")
account.username = data.username account.username = data.username
account.hash_password(data.password) account.hash_password(data.password)
@@ -376,8 +383,10 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
HTTPStatus.FORBIDDEN, "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." if data.password != data.password_repeat:
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key." raise ValueError("Passwords do not match.")
if not data.reset_key[:10].startswith("reset_key_"):
raise ValueError("This is not a reset key.")
try: try:
reset_key = base64.b64decode(data.reset_key[10:]).decode() reset_key = base64.b64decode(data.reset_key[10:]).decode()
@@ -385,12 +394,16 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
except Exception as exc: except Exception as exc:
raise ValueError("Invalid reset key.") from exc raise ValueError("Invalid reset key.") from exc
assert reset_data_json, "Cannot process reset key." if not reset_data_json:
raise ValueError("Cannot process reset key.")
action, user_id, request_time = json.loads(reset_data_json) action, user_id, request_time = json.loads(reset_data_json)
assert action, "Missing action." if not action:
assert user_id, "Missing user ID." raise ValueError("Missing action.")
assert request_time, "Missing reset time." if not user_id:
raise ValueError("Missing user ID.")
if not request_time:
raise ValueError("Missing reset time.")
_validate_auth_timeout(request_time) _validate_auth_timeout(request_time)
@@ -411,23 +424,13 @@ async def update(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username): if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if ( if (
data.username data.username
and user.username != data.username and user.username != data.username
and await get_account_by_username(data.username) and await get_account_by_username(data.username)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and data.email != user.email
and await get_account_by_email(data.email)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
account = await get_account(user.id) account = await get_account(user.id)
if not account: if not account:
@@ -435,8 +438,6 @@ async def update(
if data.username: if data.username:
account.username = data.username account.username = data.username
if data.email:
account.email = data.email
if data.extra: if data.extra:
account.extra = data.extra account.extra = data.extra
@@ -569,8 +570,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
provider_class = getattr(provider_module, f"{provider.title()}SSO") provider_class = getattr(provider_module, f"{provider.title()}SSO")
if provider_class: if provider_class:
return provider_class return provider_class
except Exception: except Exception as exc:
pass logger.debug(exc)
raise ValueError(f"No SSO provider found for '{provider}'.") raise ValueError(f"No SSO provider found for '{provider}'.")
@@ -588,28 +589,40 @@ def _nostr_nip98_event(request: Request) -> dict:
event = json.loads(event_json) event = json.loads(event_json)
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
assert event, "Nostr login event cannot be parsed." if not event:
raise ValueError("Nostr login event cannot be parsed.")
if not verify_event(event): if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
assert event["kind"] == 27_235, "Invalid event kind." if not event["kind"] == 27_235:
raise ValueError("Invalid event kind.")
auth_threshold = settings.auth_credetials_update_threshold auth_threshold = settings.auth_credetials_update_threshold
assert ( if not (abs(time() - event["created_at"]) < auth_threshold):
abs(time() - event["created_at"]) < auth_threshold raise ValueError(
), f"More than {auth_threshold} seconds have passed since the event was signed." f"More than {auth_threshold} seconds have passed "
"since the event was signed."
)
_check_nostr_event_tags(event)
return event
def _check_nostr_event_tags(event: dict):
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None) method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing." if not method:
assert method.upper() == "POST", "Invalid value for tag 'method'." raise ValueError("Tag 'method' is missing.")
if not method.upper() == "POST":
raise ValueError("Invalid value for tag 'method'.")
url = next((v for k, v in event["tags"] if k == "u"), None) url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing." if not url:
raise ValueError("Tag 'u' for URL is missing.")
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls] accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'." if url not in accepted_urls:
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
return event
def _validate_auth_timeout(auth_time: Optional[int] = 0): def _validate_auth_timeout(auth_time: Optional[int] = 0):
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter, Request
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import (
check_stripe_signature,
handle_stripe_event,
)
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@callback_router.post("/{provider_name}")
async def api_generic_webhook_handler(
provider_name: str, request: Request
) -> SimpleStatus:
if provider_name.lower() == "stripe":
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
check_stripe_signature(
payload, sig_header, settings.stripe_webhook_signing_secret
)
event = await request.json()
await handle_stripe_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
)
+63 -78
View File
@@ -170,64 +170,52 @@ async def api_enable_extension(
raise HTTPException( raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist." HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
) )
try:
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
user_ext = await get_user_extension(user.id, ext_id) logger.info(f"Enabling extension: {ext_id}.")
if not user_ext: ext = await get_installed_extension(ext_id)
user_ext = UserExtension(user=user.id, extension=ext_id, active=False) if not ext:
await create_user_extension(user_ext) raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
if user.admin or not ext.requires_payment: user_ext = await get_user_extension(user.id, ext_id)
user_ext.active = True if not user_ext:
await update_user_extension(user_ext) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.") await create_user_extension(user_ext)
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable): if user.admin or not ext.requires_payment:
raise HTTPException( user_ext.active = True
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment." await update_user_extension(user_ext)
) return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
if user_ext.is_paid: if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
user_ext.active = True raise HTTPException(
await update_user_extension(user_ext) HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status(
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
) )
if not payment_status.paid: if user_ext.is_paid:
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext) await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc: if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
except HTTPException as exc:
raise exc from exc payment_status = await check_transaction_status(
except Exception as exc: wallet_id=ext.meta.pay_to_enable.wallet,
logger.warning(exc) payment_hash=user_ext.extra.payment_hash_to_enable,
)
if not payment_status.paid:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.PAYMENT_REQUIRED,
detail=(f"Failed to enable '{ext_id}' "), f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
) from exc )
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@extension_router.put("/{ext_id}/disable") @extension_router.put("/{ext_id}/disable")
@@ -255,7 +243,8 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Activating extension: '{ext_id}'.") logger.info(f"Activating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id) ext = await get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist." if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
await activate_extension(ext) await activate_extension(ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
@@ -274,7 +263,8 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Deactivating extension: '{ext_id}'.") logger.info(f"Deactivating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id) ext = await get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist." if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
await deactivate_extension(ext_id) await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
@@ -354,40 +344,35 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
async def get_pay_to_install_invoice( async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo: ) -> ReleasePaymentInfo:
try: if ext_id != data.ext_id:
assert ( raise ValueError(
ext_id == data.ext_id f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
assert data.cost_sats, "A non-zero amount must be specified."
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
) )
assert release, "Release not found." if not data.cost_sats:
assert release.pay_link, "Pay link not found for release." raise ValueError("A non-zero amount must be specified.")
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
if not release:
raise ValueError("Release not found.")
if not release.pay_link:
raise ValueError("Pay link not found for release.")
payment_info = await release.fetch_release_payment_info(data.cost_sats) payment_info = await release.fetch_release_payment_info(data.cost_sats)
assert payment_info and payment_info.payment_request, "Cannot request invoice." if not (payment_info and payment_info.payment_request):
invoice = bolt11_decode(payment_info.payment_request) raise ValueError("Cannot request invoice.")
invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing." if invoice.amount_msat is None:
invoice_amount = int(invoice.amount_msat / 1000) raise ValueError("Invoic amount is missing.")
assert ( invoice_amount = int(invoice.amount_msat / 1000)
invoice_amount == data.cost_sats if invoice_amount != data.cost_sats:
), f"Wrong invoice amount: {invoice_amount}." raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
assert ( if payment_info.payment_hash != invoice.payment_hash:
payment_info.payment_hash == invoice.payment_hash raise ValueError("Wrong invoice payment hash.")
), "Wrong invoice payment hash."
return payment_info return payment_info
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
) from exc
@extension_router.put("/{ext_id}/invoice/enable") @extension_router.put("/{ext_id}/invoice/enable")
+18
View File
@@ -0,0 +1,18 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@fiat_router.put(
"/check/{provider}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
+6 -6
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, List, Optional, Union from typing import Annotated, Optional, Union
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import httpx import httpx
@@ -70,7 +70,7 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse) @generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)): async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts: List[InstallableExtension] = await get_installed_extensions() installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions() installable_exts = await InstallableExtension.get_installable_extensions()
@@ -206,11 +206,15 @@ async def account(
request: Request, request: Request,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
"core/account.html", "core/account.html",
{ {
"user": user.json(), "user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request), "ajax": _is_ajax_request(request),
}, },
) )
@@ -341,7 +345,6 @@ async def node(request: Request, user: User = Depends(check_admin)):
"node/index.html", "node/index.html",
{ {
"user": user.json(), "user": user.json(),
"settings": settings.dict(),
"balance": balance, "balance": balance,
"wallets": user.wallets[0].json(), "wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request), "ajax": _is_ajax_request(request),
@@ -361,7 +364,6 @@ async def node_public(request: Request):
request, request,
"node/public.html", "node/public.html",
{ {
"settings": settings.dict(),
"balance": balance, "balance": balance,
}, },
) )
@@ -380,7 +382,6 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
"admin/index.html", "admin/index.html",
{ {
"user": user.json(), "user": user.json(),
"settings": settings.dict(),
"balance": balance, "balance": balance,
"currencies": list(currencies.keys()), "currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request), "ajax": _is_ajax_request(request),
@@ -398,7 +399,6 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
{ {
"request": request, "request": request,
"user": user.json(), "user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()), "currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request), "ajax": _is_ajax_request(request),
}, },
+143
View File
@@ -0,0 +1,143 @@
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from lnurl import (
LnurlResponseException,
LnurlSuccessResponse,
)
from lnurl import execute_login as lnurlauth
from lnurl import execute_withdraw as lnurl_withdraw
from lnurl import handle as lnurl_handle
from lnurl.models import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger
from lnbits.core.models import CreateLnurlWithdraw, Payment
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
check_callback_url(res.callback)
return res
@lnurl_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> LnurlResponseModel:
check_callback_url(data.callback)
try:
res = await lnurlauth(
res=data,
seed=key_type.wallet.adminkey,
user_agent=settings.user_agent,
timeout=5,
)
return res
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
try:
res = await fetch_lnurl_pay_request(data=data)
except LnurlResponseException as exc:
logger.warning(exc)
msg = f"Failed to connect to {data.res.callback}."
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
extra: dict[str, Any] = {}
if res.success_action:
extra["success_action"] = res.success_action.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res.pr),
description=data.res.metadata.text,
extra=extra,
)
return payment
@lnurl_router.post(
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> LnurlErrorResponse | LnurlSuccessResponse:
try:
res = await lnurl_handle(
lnurl_data.lnurl_w.callback_url, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(exc)
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res, LnurlWithdrawResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
try:
res2 = await lnurl_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(exc)
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res2, (LnurlSuccessResponse, LnurlErrorResponse)):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
return res2
+14 -9
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import Optional
import httpx import httpx
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
@@ -7,8 +7,9 @@ from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.nodes import get_node_class
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import Feature
from ...db import Filters, Page from ...db import Filters, Page
from ...nodes.base import ( from ...nodes.base import (
@@ -26,9 +27,13 @@ from ...nodes.base import (
from ...utils.cache import cache from ...utils.cache import cache
def require_node(): def require_node() -> Node:
node_class = get_node_class() funding_source = get_funding_source()
if not node_class: if (
not funding_source.features
or Feature.nodemanager not in funding_source.features
or not funding_source.__node_cls__
):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED, status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API", detail="Active backend does not implement Node API",
@@ -38,7 +43,7 @@ def require_node():
status_code=HTTPStatus.SERVICE_UNAVAILABLE, status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled", detail="Not enabled",
) )
return node_class return funding_source.__node_cls__(funding_source)
def check_public(): def check_public():
@@ -91,7 +96,7 @@ async def api_get_info(
@node_router.get("/channels") @node_router.get("/channels")
async def api_get_channels( async def api_get_channels(
node: Node = Depends(require_node), node: Node = Depends(require_node),
) -> Optional[List[NodeChannel]]: ) -> Optional[list[NodeChannel]]:
return await node.get_channels() return await node.get_channels()
@@ -121,7 +126,7 @@ async def api_delete_channel(
output_index: Optional[int], output_index: Optional[int],
force: bool = False, force: bool = False,
node: Node = Depends(require_node), node: Node = Depends(require_node),
) -> Optional[List[NodeChannel]]: ) -> Optional[list[NodeChannel]]:
return await node.close_channel( return await node.close_channel(
short_id, short_id,
( (
@@ -170,7 +175,7 @@ async def api_get_invoices(
@node_router.get("/peers") @node_router.get("/peers")
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]: async def api_get_peers(node: Node = Depends(require_node)) -> list[NodePeerInfo]:
return await node.get_peers() return await node.get_peers()
+52 -220
View File
@@ -1,11 +1,7 @@
import json from hashlib import sha256
import ssl
from http import HTTPStatus from http import HTTPStatus
from math import ceil from typing import Optional
from typing import List, Optional
from urllib.parse import urlparse
import httpx
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@@ -14,7 +10,7 @@ from fastapi import (
Query, Query,
) )
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger from lnurl import decode as lnurl_decode
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
@@ -22,11 +18,10 @@ from lnbits.core.crud.payments import (
get_wallets_stats, get_wallets_stats,
) )
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice,
CreateInvoice, CreateInvoice,
CreateLnurl,
DecodePayment, DecodePayment,
KeyType, KeyType,
PayLnurlWData,
Payment, Payment,
PaymentCountField, PaymentCountField,
PaymentCountStat, PaymentCountStat,
@@ -34,13 +29,9 @@ from lnbits.core.models import (
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentWalletStats, PaymentWalletStats,
Wallet, SettleInvoice,
) )
from lnbits.core.models.users import User from lnbits.core.models.users import User
from lnbits.core.services.payments import (
get_payments_daily_stats,
update_pending_payment,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@@ -50,13 +41,10 @@ from lnbits.decorators import (
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import ( from lnbits.helpers import (
check_callback_url,
filter_dict_keys, filter_dict_keys,
generate_filter_params_openapi, generate_filter_params_openapi,
) )
from lnbits.lnurl import decode as lnurl_decode from lnbits.wallets.base import InvoiceResponse
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from ..crud import ( from ..crud import (
DateTrunc, DateTrunc,
@@ -67,9 +55,13 @@ from ..crud import (
get_wallet_for_key, get_wallet_for_key,
) )
from ..services import ( from ..services import (
create_invoice, cancel_hold_invoice,
create_payment_request,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
settle_hold_invoice,
update_pending_payment,
update_pending_payments, update_pending_payments,
) )
@@ -81,7 +73,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
name="Payment List", name="Payment List",
summary="get list of payments", summary="get list of payments",
response_description="list of payments", response_description="list of payments",
response_model=List[Payment], response_model=list[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
@@ -100,7 +92,7 @@ async def api_payments(
@payment_router.get( @payment_router.get(
"/history", "/history",
name="Get payments history", name="Get payments history",
response_model=List[PaymentHistoryPoint], response_model=list[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
@@ -115,7 +107,7 @@ async def api_payments_history(
@payment_router.get( @payment_router.get(
"/stats/count", "/stats/count",
name="Get payments history for all users", name="Get payments history for all users",
response_model=List[PaymentCountStat], response_model=list[PaymentCountStat],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_counting_stats( async def api_payments_counting_stats(
@@ -123,7 +115,6 @@ async def api_payments_counting_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@@ -137,14 +128,13 @@ async def api_payments_counting_stats(
@payment_router.get( @payment_router.get(
"/stats/wallets", "/stats/wallets",
name="Get payments history for all users", name="Get payments history for all users",
response_model=List[PaymentWalletStats], response_model=list[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_wallets_stats( async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@@ -158,14 +148,13 @@ async def api_payments_wallets_stats(
@payment_router.get( @payment_router.get(
"/stats/daily", "/stats/daily",
name="Get payments history per day", name="Get payments history per day",
response_model=List[PaymentDailyStats], response_model=list[PaymentDailyStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_daily_stats( async def api_payments_daily_stats(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@@ -198,69 +187,6 @@ async def api_payments_paginated(
return page return page
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
@payment_router.get( @payment_router.get(
"/all/paginated", "/all/paginated",
name="Payment List", name="Payment List",
@@ -308,6 +234,7 @@ async def api_payments_create(
invoice_data: CreateInvoice, invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment: ) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
@@ -315,21 +242,21 @@ async def api_payments_create(
detail="Missing BOLT11 invoice", detail="Missing BOLT11 invoice",
) )
payment = await pay_invoice( payment = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet_id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
) )
return payment return payment
elif not invoice_data.out: if invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
detail="Invoice (or Admin) key required.", detail="Invoice (or Admin) key required.",
) )
# If the payment is not outgoing, we can create a new invoice.
return await create_payment_request(wallet_id, invoice_data)
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
@@ -346,79 +273,6 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
) )
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
check_callback_url(data.callback)
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
# TODO: refactor this route into a public and admin one # TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}") @payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)): async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
@@ -439,6 +293,9 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage, "details": payment} return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
if payment.failed:
return {"paid": False, "status": "failed", "details": payment}
try: try:
status = await payment.check_status() status = await payment.check_status()
except Exception: except Exception:
@@ -474,57 +331,32 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
) )
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK) @payment_router.post("/settle")
async def api_payment_pay_with_nfc( async def api_payments_settle(
payment_request: str, data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
lnurl_data: PayLnurlWData, ) -> InvoiceResponse:
) -> JSONResponse: payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
lnurl = lnurl_data.lnurl_w.lower() payment = await get_standalone_payment(
payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await settle_hold_invoice(payment, data.preimage)
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent} @payment_router.post("/cancel")
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async def api_payments_cancel(
try: data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
check_callback_url(url) ) -> InvoiceResponse:
lnurl_req = await client.get(url, timeout=10) payment = await get_standalone_payment(
if lnurl_req.is_error: data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
return JSONResponse( )
{"success": False, "detail": "Error loading LNURL request"} if not payment:
) raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
lnurl_res = lnurl_req.json() detail="Payment does not exist or does not belong to this wallet.",
)
if lnurl_res.get("status") == "ERROR": return await cancel_hold_invoice(payment)
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
+8 -5
View File
@@ -2,7 +2,7 @@ import base64
import json import json
import time import time
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
import shortuuid import shortuuid
@@ -35,7 +35,7 @@ from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account from lnbits.core.models.users import Account
from lnbits.core.services import ( from lnbits.core.services import (
create_user_account_no_ckeck, create_user_account_no_ckeck,
enqueue_notification, enqueue_admin_notification,
update_user_account, update_user_account,
update_user_extensions, update_user_extensions,
update_wallet_balance, update_wallet_balance,
@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
username=data.username, username=data.username,
email=data.email, email=data.email,
pubkey=data.pubkey, pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra, extra=data.extra,
) )
account.validate_fields() account.validate_fields()
@@ -132,6 +133,7 @@ async def api_update_user(
username=data.username, username=data.username,
email=data.email, email=data.email,
pubkey=data.pubkey, pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra or UserExtra(), extra=data.extra or UserExtra(),
) )
await update_user_account(account) await update_user_account(account)
@@ -185,7 +187,8 @@ async def api_users_reset_password(user_id: str) -> str:
reset_data = ["reset", user_id, int(time.time())] reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False) reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json) reset_key = encrypt_internal_message(reset_data_json)
assert reset_key, "Cannot generate reset key." if not reset_key:
raise ValueError("Cannot generate reset key.")
reset_key_b64 = base64.b64encode(reset_key.encode()).decode() reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}" return f"reset_key_{reset_key_b64}"
@@ -214,7 +217,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user") @users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]: async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id) return await get_wallets(user_id)
@@ -318,7 +321,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
if not wallet: if not wallet:
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount)) await update_wallet_balance(wallet=wallet, amount=int(data.amount))
enqueue_notification( enqueue_admin_notification(
NotificationType.balance_update, NotificationType.balance_update,
{ {
"amount": int(data.amount), "amount": int(data.amount),
+4 -4
View File
@@ -252,7 +252,7 @@ class Connection(Compat):
{clause} {clause}
{group_by_string} {group_by_string}
) as count ) as count
""", """, # noqa: S608
parsed_values, parsed_values,
) )
row = result.mappings().first() row = result.mappings().first()
@@ -597,7 +597,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys]) fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders) values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" # noqa: S608
def update_query( def update_query(
@@ -615,7 +615,7 @@ def update_query(
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}') fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields) query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
def model_to_dict(model: BaseModel) -> dict: def model_to_dict(model: BaseModel) -> dict:
@@ -658,7 +658,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model) return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
""" """
Convert a dictionary with JSON-encoded nested models to a Pydantic model Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database :param _dict: Dictionary from database
+16 -3
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union from typing import Annotated, Literal, Optional, Union
import jwt import jwt
from fastapi import Cookie, Depends, Query, Request, Security from fastapi import Cookie, Depends, Query, Request, Security
@@ -28,7 +28,7 @@ from lnbits.core.models import (
WalletTypeInfo, WalletTypeInfo,
) )
from lnbits.db import Connection, Filter, Filters, TFilterModel from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import path_segments from lnbits.helpers import normalize_path, path_segments
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(
@@ -223,7 +223,7 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
return user return user
def parse_filters(model: Type[TFilterModel]): def parse_filters(model: type[TFilterModel]):
""" """
Parses the query params as filters. Parses the query params as filters.
:param model: model used for validation of filter values :param model: model used for validation of filter values
@@ -346,3 +346,16 @@ async def _check_account_api_access(
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
if not endpoint.supports_method(method): if not endpoint.supports_method(method):
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
def url_for_interceptor(original_method):
def normalize_url(self, *args, **kwargs):
url = original_method(self, *args, **kwargs)
return url.replace(path=normalize_path(url.path))
return normalize_url
# Upgraded extensions modify the path.
# This interceptor ensures that the path is normalized.
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
+6 -2
View File
@@ -26,6 +26,10 @@ class InvoiceError(Exception):
self.status = status self.status = status
class UnsupportedError(Exception):
pass
def render_html_error(request: Request, exc: Exception) -> Optional[Response]: def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request # Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response # not fail proof, but everything else get's a JSON response
@@ -65,7 +69,7 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
) )
def register_exception_handlers(app: FastAPI): def register_exception_handlers(app: FastAPI): # noqa: C901
"""Register exception handlers for the FastAPI app""" """Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception) @app.exception_handler(Exception)
@@ -147,7 +151,7 @@ def register_exception_handlers(app: FastAPI):
status_code = HTTPStatus.NOT_FOUND status_code = HTTPStatus.NOT_FOUND
message: str = "Page not found." message: str = "Page not found."
if path in settings.lnbits_all_extensions_ids: if settings.is_ready_to_install_extension_id(path):
status_code = HTTPStatus.FORBIDDEN status_code = HTTPStatus.FORBIDDEN
message = f"Extension '{path}' not installed. Ask the admin to install it." message = f"Extension '{path}' not installed. Ask the admin to install it."
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import importlib
from enum import Enum
from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
if name not in FiatProviderType.__members__:
raise ValueError(f"Fiat provider '{name}' is not supported.")
fiat_provider = fiat_providers.get(name)
if fiat_provider:
status = await fiat_provider.status(only_check_settings=True)
if status.error_message:
await fiat_provider.cleanup()
del fiat_providers[name]
else:
return fiat_provider
_provider = _init_fiat_provider(FiatProviderType[name])
if not _provider:
return None
fiat_providers[name] = _provider
return fiat_providers[name]
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider | None:
if not settings.is_fiat_provider_enabled(fiat_provider.name):
logger.warning(f"Fiat provider '{fiat_provider.name}' not enabled.")
return None
provider_constructor = getattr(fiat_module, fiat_provider.value)
return provider_constructor()
fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"StripeWallet",
]
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
pass
class FiatStatusResponse(NamedTuple):
error_message: str | None = None
balance: float = 0
class FiatInvoiceResponse(NamedTuple):
ok: bool
checking_id: str | None = None # payment_hash, rpc_id
payment_request: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class FiatPaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded
ok: bool | None = None
checking_id: str | None = None # payment_hash, rcp_id
fee: float | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class FiatPaymentStatus(NamedTuple):
paid: bool | None = None
fee: float | None = None # todo: what fee is this?
@property
def success(self) -> bool:
return self.paid is True
@property
def pending(self) -> bool:
return self.paid is not True
@property
def failed(self) -> bool:
return self.paid is False
def __str__(self) -> str:
if self.success:
return "success"
if self.failed:
return "failed"
return "pending"
class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True
class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False
class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None
class FiatProvider(ABC):
@abstractmethod
async def cleanup(self):
pass
@abstractmethod
def status(
self, only_check_settings: bool | None = False
) -> Coroutine[None, None, FiatStatusResponse]:
pass
@abstractmethod
def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
**kwargs,
) -> Coroutine[None, None, FiatInvoiceResponse]:
pass
@abstractmethod
def pay_invoice(
self,
payment_request: str,
) -> Coroutine[None, None, FiatPaymentResponse]:
pass
@abstractmethod
def get_invoice_status(
self, checking_id: str
) -> Coroutine[None, None, FiatPaymentStatus]:
pass
@abstractmethod
def get_payment_status(
self, checking_id: str
) -> Coroutine[None, None, FiatPaymentStatus]:
pass
async def paid_invoices_stream(
self,
) -> AsyncGenerator[str, None]:
yield ""
+174
View File
@@ -0,0 +1,174 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlencode
import httpx
from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
)
class StripeWallet(FiatProvider):
"""https://docs.stripe.com/api"""
def __init__(self):
logger.debug("Initializing StripeWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.stripe_api_endpoint:
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
if not settings.stripe_api_secret_key:
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("StripeWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing stripe wallet connection: {e}")
async def status(
self, only_check_settings: Optional[bool] = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status()
data = r.json()
available_balance = data.get("available", [{}])[0].get("amount", 0)
# pending_balance = data.get("pending", {}).get("amount", 0)
return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: Optional[str] = None,
**kwargs,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
form_data = [
("mode", "payment"),
(
"success_url",
settings.stripe_payment_success_url or "https://lnbits.com",
),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
("line_items[0][price_data][unit_amount]", amount_cents),
("line_items[0][quantity]", "1"),
]
encoded_data = urlencode(form_data)
try:
headers = self.headers.copy()
headers["Content-Type"] = "application/x-www-form-urlencoded"
r = await self.client.post(
url="/v1/checkout/sessions", headers=headers, content=encoded_data
)
r.raise_for_status()
data = r.json()
session_id = data.get("id")
if not session_id:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing session id'"
)
payment_request = data.get("url")
if not payment_request:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing payment URL'"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=payment_request
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Stripe does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
r = await self.client.get(
url=f"/v1/checkout/sessions/{checking_id}",
)
r.raise_for_status()
data = r.json()
payment_status = data.get("payment_status")
if not payment_status:
return FiatPaymentPendingStatus()
if payment_status == "paid":
# todo: handle fee
return FiatPaymentSuccessStatus()
expires_at = data.get("expires_at")
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and expires_at < _24_hours_ago.timestamp():
# be defensive: add a 24 hour buffer
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
except Exception as exc:
logger.debug(f"Error getting invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Stripe does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Stripe does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _settings_connection_fields(self) -> str:
return "-".join(
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
)
+30 -9
View File
@@ -3,7 +3,7 @@ import json
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Type from typing import Any, Optional
from urllib import request from urllib import request
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -16,8 +16,6 @@ from packaging import version
from pydantic.schema import field_schema from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher from lnbits.utils.crypto import AESCipher
@@ -42,7 +40,7 @@ def urlsafe_short_hash() -> str:
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str: def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
base = g().base_url if external else "" base = f"http://{settings.host}:{settings.port}" if external else ""
url_params = "?" url_params = "?"
for key, value in params.items(): for key, value in params.items():
url_params += f"{key}={value}&" url_params += f"{key}={value}&"
@@ -84,8 +82,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color, "LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all, "LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed, "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": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None, "LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo, "LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee, "LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max, "LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
@@ -106,6 +104,7 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
if settings.lnbits_denomination == "FakeWallet" if settings.lnbits_denomination == "FakeWallet"
else "sats" else "sats"
), ),
"has_holdinvoice": settings.has_holdinvoice,
} }
t.env.globals["WINDOW_SETTINGS"] = window_settings t.env.globals["WINDOW_SETTINGS"] = window_settings
@@ -154,7 +153,7 @@ def get_current_extension_name() -> str:
return ext_name return ext_name
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False): def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False):
""" """
Generate openapi documentation for Filters. This is intended to be used along Generate openapi documentation for Filters. This is intended to be used along
parse_filters (see example) parse_filters (see example)
@@ -198,6 +197,14 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None return re.fullmatch(username_regex, username) is not None
def is_valid_external_id(external_id: str) -> bool:
if len(external_id) > 256:
return False
if " " in external_id or "\n" in external_id:
return False
return True
def is_valid_pubkey(pubkey: str) -> bool: def is_valid_pubkey(pubkey: str) -> bool:
if len(pubkey) != 64: if len(pubkey) != 64:
return False return False
@@ -299,8 +306,11 @@ def check_callback_url(url: str):
) )
def download_url(url, save_path): def download_url(url: str, save_path: Path):
with request.urlopen(url, timeout=60) as dl_file: if not url.startswith(("http:", "https:")):
raise ValueError(f"Invalid URL: {url}. Must start with 'http' or 'https'.")
with request.urlopen(url, timeout=60) as dl_file: # noqa: S310
with open(save_path, "wb") as out_file: with open(save_path, "wb") as out_file:
out_file.write(dl_file.read()) out_file.write(dl_file.read())
@@ -355,3 +365,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
# Prevent filename with subdirectories # Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1] file_path = image_folder / filename.split("/")[-1]
return file_path.resolve() return file_path.resolve()
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
return endpoint
endpoint = (
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
)
return endpoint
-66
View File
@@ -1,66 +0,0 @@
from http import HTTPStatus
from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=HTTPStatus.OK,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"decode",
"encode",
"handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
]
+2 -3
View File
@@ -2,7 +2,7 @@ import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Any, List, Optional, Union from typing import Any, Optional, Union
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _response_by_accepted_type( def _response_by_accepted_type(
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
) -> Union[HTMLResponse, JSONResponse]: ) -> Union[HTMLResponse, JSONResponse]:
""" """
Build an HTTP response containing the `msg` as HTTP body and the `status_code` Build an HTTP response containing the `msg` as HTTP body and the `status_code`
@@ -131,7 +131,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
try: try:
response = await call_next(request) response = await call_next(request)
assert response
return response return response
finally: finally:
if request_details: if request_details:
-15
View File
@@ -1,15 +0,0 @@
from typing import Optional
from .base import Node
def get_node_class() -> Optional[Node]:
return NODE
def set_node_class(node: Node):
global NODE
NODE = node
NODE: Optional[Node] = None
+1 -1
View File
@@ -11,12 +11,12 @@ from httpx import HTTPStatusError
from loguru import logger from loguru import logger
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import ( from lnbits.nodes.base import (
ChannelBalance, ChannelBalance,
ChannelPoint, ChannelPoint,
ChannelState, ChannelState,
ChannelStats, ChannelStats,
Node,
NodeChannel, NodeChannel,
NodeFees, NodeFees,
NodeInfoResponse, NodeInfoResponse,
-10
View File
@@ -1,10 +0,0 @@
import contextvars
import types
request_global = contextvars.ContextVar(
"request_global", default=types.SimpleNamespace()
)
def g() -> types.SimpleNamespace:
return request_global.get()
+114 -7
View File
@@ -201,10 +201,11 @@ class InstalledExtensionsSettings(LNbitsSettings):
if r.find_in_conflict(ext_redirect_paths) if r.find_in_conflict(ext_redirect_paths)
} }
assert len(existing_redirects) == 0, ( if len(existing_redirects) != 0:
f"Cannot redirect for extension '{ext_id}'." raise ValueError(
f" Already mapped by {existing_redirects}." f"Cannot redirect for extension '{ext_id}'."
) f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id) self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths self.lnbits_extensions_redirects += ext_redirect_paths
@@ -255,7 +256,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg") lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by") lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field( lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png" default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -305,7 +306,7 @@ class FeeSettings(LNbitsSettings):
return 0 return 0
reserve_min = self.lnbits_reserve_fee_min reserve_min = self.lnbits_reserve_fee_min
reserve_percent = self.lnbits_reserve_fee_percent reserve_percent = self.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0)) return max(int(reserve_min), int(abs(amount_msat) * reserve_percent / 100.0))
class ExchangeProvidersSettings(LNbitsSettings): class ExchangeProvidersSettings(LNbitsSettings):
@@ -431,6 +432,18 @@ class NotificationsSettings(LNbitsSettings):
default=1_000_000, ge=0 default=1_000_000, ge=0
) )
def is_nostr_notifications_configured(self) -> bool:
return (
self.lnbits_nostr_notifications_enabled
and self.lnbits_nostr_notifications_private_key is not None
)
def is_telegram_notifications_configured(self) -> bool:
return (
self.lnbits_telegram_notifications_enabled
and self.lnbits_telegram_notifications_access_token is not None
)
class FakeWalletFundingSource(LNbitsSettings): class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1") fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -448,6 +461,18 @@ class ClicheFundingSource(LNbitsSettings):
cliche_endpoint: str | None = Field(default=None) cliche_endpoint: str | None = Field(default=None)
class CLNRestFundingSource(LNbitsSettings):
clnrest_url: str | None = Field(default=None)
clnrest_ca: str | None = Field(default=None)
clnrest_cert: str | None = Field(default=None)
clnrest_readonly_rune: str | None = Field(default=None)
clnrest_invoice_rune: str | None = Field(default=None)
clnrest_pay_rune: str | None = Field(default=None)
clnrest_renepay_rune: str | None = Field(default=None)
clnrest_last_pay_index: str | None = Field(default=None)
clnrest_nodeid: str | None = Field(default=None)
class CoreLightningFundingSource(LNbitsSettings): class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: str | None = Field(default=None) corelightning_rpc: str | None = Field(default=None)
corelightning_pay_command: str = Field(default="pay") corelightning_pay_command: str = Field(default="pay")
@@ -549,6 +574,12 @@ class BreezSdkFundingSource(LNbitsSettings):
breez_use_trampoline: bool = Field(default=True) breez_use_trampoline: bool = Field(default=True)
class BreezLiquidSdkFundingSource(LNbitsSettings):
breez_liquid_api_key: str | None = Field(default=None)
breez_liquid_seed: str | None = Field(default=None)
breez_liquid_fee_offset_sat: int = Field(default=50)
class BoltzFundingSource(LNbitsSettings): class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002") boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None) boltz_client_macaroon: str | None = Field(default=None)
@@ -565,6 +596,34 @@ class StrikeFundingSource(LNbitsSettings):
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY") strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel):
# empty list means all users are allowed to receive payments via Stripe
allowed_users: list[str] = Field(default=[])
service_max_fee_sats: int = Field(default=0)
service_fee_percent: float = Field(default=0)
service_fee_wallet_id: str | None = Field(default=None)
service_min_amount_sats: int = Field(default=0)
service_max_amount_sats: int = Field(default=0)
service_faucet_wallet_id: str | None = Field(default="")
class StripeFiatProvider(LNbitsSettings):
stripe_enabled: bool = Field(default=False)
stripe_api_endpoint: str = Field(default="https://api.stripe.com")
stripe_api_secret_key: str | None = Field(default=None)
stripe_payment_success_url: str = Field(default="https://lnbits.com")
stripe_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/stripe"
)
# Use this secret to verify that events come from Stripe.
stripe_webhook_signing_secret: str | None = Field(default=None)
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -573,6 +632,7 @@ class FundingSourcesSettings(
FakeWalletFundingSource, FakeWalletFundingSource,
LNbitsFundingSource, LNbitsFundingSource,
ClicheFundingSource, ClicheFundingSource,
CLNRestFundingSource,
CoreLightningFundingSource, CoreLightningFundingSource,
CoreLightningRestFundingSource, CoreLightningRestFundingSource,
EclairFundingSource, EclairFundingSource,
@@ -590,6 +650,7 @@ class FundingSourcesSettings(
NWCFundingSource, NWCFundingSource,
BreezSdkFundingSource, BreezSdkFundingSource,
StrikeFundingSource, StrikeFundingSource,
BreezLiquidSdkFundingSource,
): ):
lnbits_backend_wallet_class: str = Field(default="VoidWallet") lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status # How long to wait for the payment to be confirmed before returning a pending status
@@ -597,6 +658,40 @@ class FundingSourcesSettings(
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0) lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
class FiatProvidersSettings(StripeFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
"""
if not provider:
return False
if provider == "stripe":
return self.stripe_enabled
# Add checks for other fiat providers here as needed
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
"""
Returns a list of fiat payment methods allowed for the user.
"""
allowed_providers = []
if self.stripe_enabled and (
not self.stripe_limits.allowed_users
or user_id in self.stripe_limits.allowed_users
):
allowed_providers.append("stripe")
# Add other fiat providers here as needed
return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
"""
Returns the limits for a specific fiat provider.
"""
return getattr(self, provider_name + "_limits", None)
class WebPushSettings(LNbitsSettings): class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str | None = Field(default=None) lnbits_webpush_pubkey: str | None = Field(default=None)
lnbits_webpush_privkey: str | None = Field(default=None) lnbits_webpush_privkey: str | None = Field(default=None)
@@ -614,7 +709,7 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum): class AuthMethods(Enum):
user_id_only = "user-id-only" user_id_only = "user-id-only"
username_and_password = "username-password" username_and_password = "username-password" # noqa: S105
nostr_auth_nip98 = "nostr-auth-nip98" nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth" google_auth = "google-auth"
github_auth = "github-auth" github_auth = "github-auth"
@@ -769,6 +864,7 @@ class EditableSettings(
SecuritySettings, SecuritySettings,
NotificationsSettings, NotificationsSettings,
FundingSourcesSettings, FundingSourcesSettings,
FiatProvidersSettings,
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
@@ -861,6 +957,8 @@ class SuperUserSettings(LNbitsSettings):
"BoltzWallet", "BoltzWallet",
"BlinkWallet", "BlinkWallet",
"BreezSdkWallet", "BreezSdkWallet",
"BreezLiquidSdkWallet",
"CLNRestWallet",
"CoreLightningRestWallet", "CoreLightningRestWallet",
"CoreLightningWallet", "CoreLightningWallet",
"EclairWallet", "EclairWallet",
@@ -901,6 +999,9 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
server_startup_time: int = Field(default=time()) server_startup_time: int = Field(default=time())
has_holdinvoice: bool = Field(default=False)
has_nodemanager: bool = Field(default=False)
@property @property
def lnbits_server_up_time(self) -> str: def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time) up_time = int(time() - self.server_startup_time)
@@ -963,6 +1064,12 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
def is_installed_extension_id(self, ext_id: str) -> bool: def is_installed_extension_id(self, ext_id: str) -> bool:
return ext_id in self.lnbits_installed_extensions_ids return ext_id in self.lnbits_installed_extensions_ids
def is_ready_to_install_extension_id(self, ext_id: str) -> bool:
return (
ext_id not in self.lnbits_installed_extensions_ids
and ext_id in self.lnbits_all_extensions_ids
)
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):
super_user: str super_user: str
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
+11 -11
View File
File diff suppressed because one or more lines are too long
+173 -572
View File
@@ -1,617 +1,218 @@
body[data-theme=classic].body--light { body[data-theme=classic].neon-border .q-card,
background-color: #f4f4f4; body[data-theme=classic].neon-border .q-card.q-card--dark,
body[data-theme=classic].neon-border .q-date,
body[data-theme=classic].neon-border .q-date--dark {
border: 2px solid #673ab7;
box-shadow: none;
} }
body[data-theme=classic].body--light { body[data-theme=bitcoin].neon-border .q-card,
background-color: #f4f4f4; body[data-theme=bitcoin].neon-border .q-card.q-card--dark,
body[data-theme=bitcoin].neon-border .q-date,
body[data-theme=bitcoin].neon-border .q-date--dark {
border: 2px solid #ea611d;
box-shadow: none;
} }
[data-theme=classic] .q-drawer--dark, body[data-theme=freedom].neon-border .q-card,
body[data-theme=classic].body--dark, body[data-theme=freedom].neon-border .q-card.q-card--dark,
[data-theme=classic] .q-menu--dark { body[data-theme=freedom].neon-border .q-date,
background: #1f2234 !important; body[data-theme=freedom].neon-border .q-date--dark {
border: 2px solid #e22156;
box-shadow: none;
} }
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE body[data-theme=cyber].neon-border .q-card,
// set a darker body bg for all themes, when in "dark mode" body[data-theme=cyber].neon-border .q-card.q-card--dark,
body[data-theme='classic'].body--dark { body[data-theme=cyber].neon-border .q-date,
background: scale-color($color, $lightness: -60%); body[data-theme=cyber].neon-border .q-date--dark {
} border: 2px solid #7cb342;
*/ box-shadow: none;
body[data-theme=classic].body--light {
background-color: #f4f4f4;
} }
[data-theme=classic] .q-card--dark, body[data-theme=mint].neon-border .q-card,
[data-theme=classic] .q-stepper--dark { body[data-theme=mint].neon-border .q-card.q-card--dark,
body[data-theme=mint].neon-border .q-date,
body[data-theme=mint].neon-border .q-date--dark {
border: 2px solid #3ab77d;
box-shadow: none;
}
body[data-theme=autumn].neon-border .q-card,
body[data-theme=autumn].neon-border .q-card.q-card--dark,
body[data-theme=autumn].neon-border .q-date,
body[data-theme=autumn].neon-border .q-date--dark {
border: 2px solid #b7763a;
box-shadow: none;
}
body[data-theme=flamingo].neon-border .q-card,
body[data-theme=flamingo].neon-border .q-card.q-card--dark,
body[data-theme=flamingo].neon-border .q-date,
body[data-theme=flamingo].neon-border .q-date--dark {
border: 2px solid #ff00ff;
box-shadow: none;
}
body[data-theme=monochrome].neon-border .q-card,
body[data-theme=monochrome].neon-border .q-card.q-card--dark,
body[data-theme=monochrome].neon-border .q-date,
body[data-theme=monochrome].neon-border .q-date--dark {
border: 2px solid #494949;
box-shadow: none;
}
body[data-theme=salvador].neon-border .q-card,
body[data-theme=salvador].neon-border .q-card.q-card--dark,
body[data-theme=salvador].neon-border .q-date,
body[data-theme=salvador].neon-border .q-date--dark {
border: 2px solid #1976d2;
box-shadow: none;
}
body.hard-border .q-card,
body.hard-border .q-card.q-card--dark,
body.hard-border .q-date,
body.hard-border .q-date--dark {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.2784313725);
border: none;
}
body.retro-border .q-card,
body.retro-border .q-card.q-card--dark,
body.retro-border .q-date,
body.retro-border .q-date--dark {
border: none;
box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);
}
body.no-border .q-card,
body.no-border .q-card.q-card--dark,
body.no-border .q-date,
body.no-border .q-date--dark {
border: none;
box-shadow: none;
}
body[data-theme=classic] {
--q-primary: #673ab7;
--q-secondary: #9c27b0;
--q-dark-page: #1f2234;
}
body[data-theme=classic] [data-theme=classic] .q-card--dark,
body[data-theme=classic] [data-theme=classic] .q-stepper--dark {
background: #333646 !important; background: #333646 !important;
} }
body[data-theme=classic].body--light { body[data-theme=bitcoin] {
background-color: #f4f4f4; --q-primary: #ea611d;
--q-secondary: #e56f35;
--q-dark-page: #2d293b;
} }
body[data-theme=bitcoin] [data-theme=bitcoin] .q-card--dark,
body[data-theme=classic].body--light { body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
background-color: #f4f4f4;
}
[data-theme=classic] .bg-primary {
background: #673ab7 !important;
}
[data-theme=classic] .text-primary {
color: #673ab7 !important;
}
[data-theme=classic] .bg-secondary {
background: #9c27b0 !important;
}
[data-theme=classic] .text-secondary {
color: #9c27b0 !important;
}
[data-theme=classic] .bg-dark {
background: #1f2234 !important;
}
[data-theme=classic] .text-dark {
color: #1f2234 !important;
}
[data-theme=classic] .bg-info {
background: #333646 !important;
}
[data-theme=classic] .text-info {
color: #333646 !important;
}
[data-theme=classic] .bg-marginal-bg {
background: #1f2234 !important;
}
[data-theme=classic] .text-marginal-bg {
color: #1f2234 !important;
}
[data-theme=classic] .bg-marginal-text {
background: #fff !important;
}
[data-theme=classic] .text-marginal-text {
color: #fff !important;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
[data-theme=bitcoin] .q-drawer--dark,
body[data-theme=bitcoin].body--dark,
[data-theme=bitcoin] .q-menu--dark {
background: #2d293b !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='bitcoin'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
[data-theme=bitcoin] .q-card--dark,
[data-theme=bitcoin] .q-stepper--dark {
background: #333646 !important; background: #333646 !important;
} }
body[data-theme=bitcoin].body--light { body[data-theme=freedom] {
background-color: #f4f4f4; --q-primary: #e22156;
--q-secondary: #b91a45;
--q-dark-page: #462f36;
}
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
background: #47393d !important;
} }
body[data-theme=bitcoin].body--light { body[data-theme=cyber] {
background-color: #f4f4f4; --q-primary: #7cb342;
--q-secondary: #558b2f;
--q-dark-page: #000;
}
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
background: #1f2915 !important;
} }
[data-theme=bitcoin] .bg-primary { body[data-theme=mint] {
background: #ea611d !important; --q-primary: #3ab77d;
--q-secondary: #27b065;
--q-dark-page: #1f342b;
} }
[data-theme=bitcoin] .text-primary { body[data-theme=mint] [data-theme=mint] .q-card--dark,
color: #ea611d !important; body[data-theme=mint] [data-theme=mint] .q-stepper--dark {
}
[data-theme=bitcoin] .bg-secondary {
background: #e56f35 !important;
}
[data-theme=bitcoin] .text-secondary {
color: #e56f35 !important;
}
[data-theme=bitcoin] .bg-dark {
background: #2d293b !important;
}
[data-theme=bitcoin] .text-dark {
color: #2d293b !important;
}
[data-theme=bitcoin] .bg-info {
background: #333646 !important;
}
[data-theme=bitcoin] .text-info {
color: #333646 !important;
}
[data-theme=bitcoin] .bg-marginal-bg {
background: #000000 !important;
}
[data-theme=bitcoin] .text-marginal-bg {
color: #000000 !important;
}
[data-theme=bitcoin] .bg-marginal-text {
background: #fff !important;
}
[data-theme=bitcoin] .text-marginal-text {
color: #fff !important;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .q-drawer--dark,
body[data-theme=freedom].body--dark,
[data-theme=freedom] .q-menu--dark {
background: #0a0a0a !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='freedom'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .q-card--dark,
[data-theme=freedom] .q-stepper--dark {
background: #1b1b1b !important;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .bg-primary {
background: #e22156 !important;
}
[data-theme=freedom] .text-primary {
color: #e22156 !important;
}
[data-theme=freedom] .bg-secondary {
background: #b91a45 !important;
}
[data-theme=freedom] .text-secondary {
color: #b91a45 !important;
}
[data-theme=freedom] .bg-dark {
background: #0a0a0a !important;
}
[data-theme=freedom] .text-dark {
color: #0a0a0a !important;
}
[data-theme=freedom] .bg-info {
background: #1b1b1b !important;
}
[data-theme=freedom] .text-info {
color: #1b1b1b !important;
}
[data-theme=freedom] .bg-marginal-bg {
background: #2d293b !important;
}
[data-theme=freedom] .text-marginal-bg {
color: #2d293b !important;
}
[data-theme=freedom] .bg-marginal-text {
background: #fff !important;
}
[data-theme=freedom] .text-marginal-text {
color: #fff !important;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .q-drawer--dark,
body[data-theme=cyber].body--dark,
[data-theme=cyber] .q-menu--dark {
background: #0a0a0a !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='cyber'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .q-card--dark,
[data-theme=cyber] .q-stepper--dark {
background: #1b1b1b !important;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .bg-primary {
background: #7cb342 !important;
}
[data-theme=cyber] .text-primary {
color: #7cb342 !important;
}
[data-theme=cyber] .bg-secondary {
background: #558b2f !important;
}
[data-theme=cyber] .text-secondary {
color: #558b2f !important;
}
[data-theme=cyber] .bg-dark {
background: #0a0a0a !important;
}
[data-theme=cyber] .text-dark {
color: #0a0a0a !important;
}
[data-theme=cyber] .bg-info {
background: #1b1b1b !important;
}
[data-theme=cyber] .text-info {
color: #1b1b1b !important;
}
[data-theme=cyber] .bg-marginal-bg {
background: #2d293b !important;
}
[data-theme=cyber] .text-marginal-bg {
color: #2d293b !important;
}
[data-theme=cyber] .bg-marginal-text {
background: #fff !important;
}
[data-theme=cyber] .text-marginal-text {
color: #fff !important;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
[data-theme=mint] .q-drawer--dark,
body[data-theme=mint].body--dark,
[data-theme=mint] .q-menu--dark {
background: #1f342b !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='mint'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
[data-theme=mint] .q-card--dark,
[data-theme=mint] .q-stepper--dark {
background: #334642 !important; background: #334642 !important;
} }
body[data-theme=mint].body--light { body[data-theme=autumn] {
background-color: #f4f4f4; --q-primary: #b7763a;
--q-secondary: #b07927;
--q-dark-page: #34291f;
} }
body[data-theme=autumn] [data-theme=autumn] .q-card--dark,
body[data-theme=mint].body--light { body[data-theme=autumn] [data-theme=autumn] .q-stepper--dark {
background-color: #f4f4f4;
}
[data-theme=mint] .bg-primary {
background: #3ab77d !important;
}
[data-theme=mint] .text-primary {
color: #3ab77d !important;
}
[data-theme=mint] .bg-secondary {
background: #27b065 !important;
}
[data-theme=mint] .text-secondary {
color: #27b065 !important;
}
[data-theme=mint] .bg-dark {
background: #1f342b !important;
}
[data-theme=mint] .text-dark {
color: #1f342b !important;
}
[data-theme=mint] .bg-info {
background: #334642 !important;
}
[data-theme=mint] .text-info {
color: #334642 !important;
}
[data-theme=mint] .bg-marginal-bg {
background: #1f342b !important;
}
[data-theme=mint] .text-marginal-bg {
color: #1f342b !important;
}
[data-theme=mint] .bg-marginal-text {
background: #fff !important;
}
[data-theme=mint] .text-marginal-text {
color: #fff !important;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
[data-theme=autumn] .q-drawer--dark,
body[data-theme=autumn].body--dark,
[data-theme=autumn] .q-menu--dark {
background: #34291f !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='autumn'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
[data-theme=autumn] .q-card--dark,
[data-theme=autumn] .q-stepper--dark {
background: #463f33 !important; background: #463f33 !important;
} }
body[data-theme=autumn].body--light { body[data-theme=flamingo] {
background-color: #f4f4f4; --q-primary: #ff00ff;
--q-secondary: #fda3fd;
--q-dark-page: #2f032f;
} }
body[data-theme=flamingo] [data-theme=flamingo] .q-card--dark,
body[data-theme=autumn].body--light { body[data-theme=flamingo] [data-theme=flamingo] .q-stepper--dark {
background-color: #f4f4f4;
}
[data-theme=autumn] .bg-primary {
background: #b7763a !important;
}
[data-theme=autumn] .text-primary {
color: #b7763a !important;
}
[data-theme=autumn] .bg-secondary {
background: #b07927 !important;
}
[data-theme=autumn] .text-secondary {
color: #b07927 !important;
}
[data-theme=autumn] .bg-dark {
background: #34291f !important;
}
[data-theme=autumn] .text-dark {
color: #34291f !important;
}
[data-theme=autumn] .bg-info {
background: #463f33 !important;
}
[data-theme=autumn] .text-info {
color: #463f33 !important;
}
[data-theme=autumn] .bg-marginal-bg {
background: #342a1f !important;
}
[data-theme=autumn] .text-marginal-bg {
color: #342a1f !important;
}
[data-theme=autumn] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=autumn] .text-marginal-text {
color: rgb(255, 255, 255) !important;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
[data-theme=flamingo] .q-drawer--dark,
body[data-theme=flamingo].body--dark,
[data-theme=flamingo] .q-menu--dark {
background: #2f032f !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='flamingo'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
[data-theme=flamingo] .q-card--dark,
[data-theme=flamingo] .q-stepper--dark {
background: #bc23bc !important; background: #bc23bc !important;
} }
body[data-theme=flamingo].body--light { body[data-theme=monochrome] {
background-color: #f4f4f4; --q-primary: #494949;
--q-secondary: #6b6b6b;
--q-dark-page: #000;
} }
body[data-theme=monochrome] [data-theme=monochrome] .q-card--dark,
body[data-theme=flamingo].body--light { body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
background-color: #f4f4f4;
}
[data-theme=flamingo] .bg-primary {
background: #ff00ff !important;
}
[data-theme=flamingo] .text-primary {
color: #ff00ff !important;
}
[data-theme=flamingo] .bg-secondary {
background: #fda3fd !important;
}
[data-theme=flamingo] .text-secondary {
color: #fda3fd !important;
}
[data-theme=flamingo] .bg-dark {
background: #2f032f !important;
}
[data-theme=flamingo] .text-dark {
color: #2f032f !important;
}
[data-theme=flamingo] .bg-info {
background: #bc23bc !important;
}
[data-theme=flamingo] .text-info {
color: #bc23bc !important;
}
[data-theme=flamingo] .bg-marginal-bg {
background: #311231 !important;
}
[data-theme=flamingo] .text-marginal-bg {
color: #311231 !important;
}
[data-theme=flamingo] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=flamingo] .text-marginal-text {
color: rgb(255, 255, 255) !important;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
[data-theme=monochrome] .q-drawer--dark,
body[data-theme=monochrome].body--dark,
[data-theme=monochrome] .q-menu--dark {
background: #000 !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='monochrome'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
[data-theme=monochrome] .q-card--dark,
[data-theme=monochrome] .q-stepper--dark {
background: rgb(39, 39, 39) !important; background: rgb(39, 39, 39) !important;
} }
body[data-theme=monochrome].body--light { body[data-theme=salvador] {
background-color: #f4f4f4; --q-primary: #1976d2;
--q-secondary: #26a69a;
--q-dark-page: #253647;
}
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
background: #343d47 !important;
} }
body[data-theme=monochrome].body--light { body.gradient-bg {
background-color: #f4f4f4; background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
background-attachment: fixed;
} }
[data-theme=monochrome] .bg-primary { body.bg-image::before {
background: #494949 !important; content: "";
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(8px);
background-image: var(--background);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
[data-theme=monochrome] .text-primary { body.bg-image .q-page-container {
color: #494949 !important; backdrop-filter: none; /* Ensure the page content is not affected */
}
[data-theme=monochrome] .bg-secondary {
background: #6b6b6b !important;
}
[data-theme=monochrome] .text-secondary {
color: #6b6b6b !important;
}
[data-theme=monochrome] .bg-dark {
background: #000 !important;
}
[data-theme=monochrome] .text-dark {
color: #000 !important;
}
[data-theme=monochrome] .bg-info {
background: rgb(39, 39, 39) !important;
}
[data-theme=monochrome] .text-info {
color: rgb(39, 39, 39) !important;
}
[data-theme=monochrome] .bg-marginal-bg {
background: #000 !important;
}
[data-theme=monochrome] .text-marginal-bg {
color: #000 !important;
}
[data-theme=monochrome] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=monochrome] .text-marginal-text {
color: rgb(255, 255, 255) !important;
} }
[data-theme=freedom] .q-drawer--dark { body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
background: #0a0a0a !important; body.body--dark .q-header,
} body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
[data-theme=freedom] .q-header { background-color: var(--q-dark);
background: #0a0a0a !important; backdrop-filter: blur(6px);
}
[data-theme=cyber] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme=cyber] .q-header {
background: #0a0a0a !important;
}
[data-theme=salvador] .q-drawer--dark {
background: #242424 !important;
}
[data-theme=salvador] .q-header {
background: #0f47af !important;
} }
[v-cloak] { [v-cloak] {
+1 -1
View File
@@ -338,7 +338,7 @@ window.localisation.br = {
node_balance: 'Saldo do Nó: {balance} sats', node_balance: 'Saldo do Nó: {balance} sats',
lnbits_balance: 'Saldo do LNbits: {balance} sats', lnbits_balance: 'Saldo do LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Percentual: {percent} %', funding_reserve_percent: 'Reserve Percentual: {percent} %',
node_managment: 'Gerenciamento de Nós', node_management: 'Gerenciamento de Nós',
node_management_not_supported: node_management_not_supported:
'Gerenciamento de nó não suportado pela fonte de financiamento ativa', 'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
toggle_node_ui: 'Interface do Nó', toggle_node_ui: 'Interface do Nó',
+1 -1
View File
@@ -320,7 +320,7 @@ window.localisation.cn = {
node_balance: '节点余额:{balance} sats', node_balance: '节点余额:{balance} sats',
lnbits_balance: 'LNbits 余额:{balance} sats', lnbits_balance: 'LNbits 余额:{balance} sats',
funding_reserve_percent: '保留百分比: {percent} %', funding_reserve_percent: '保留百分比: {percent} %',
node_managment: '节点管理', node_management: '节点管理',
node_management_not_supported: '活动资金来源不支持节点管理', node_management_not_supported: '活动资金来源不支持节点管理',
toggle_node_ui: '节点用户界面', toggle_node_ui: '节点用户界面',
toggle_public_node_ui: '公共节点用户界面', toggle_public_node_ui: '公共节点用户界面',
+1 -1
View File
@@ -332,7 +332,7 @@ window.localisation.cs = {
node_balance: 'Stav uzlu: {balance} sats', node_balance: 'Stav uzlu: {balance} sats',
lnbits_balance: 'Zůstatek LNbits: {balance} sats', lnbits_balance: 'Zůstatek LNbits: {balance} sats',
funding_reserve_percent: 'Rezervovat procento: {percent} %', funding_reserve_percent: 'Rezervovat procento: {percent} %',
node_managment: 'Správa uzlů', node_management: 'Správa uzlů',
node_management_not_supported: node_management_not_supported:
'Správa uzlů není podporována aktivním zdrojem financování', 'Správa uzlů není podporována aktivním zdrojem financování',
toggle_node_ui: 'Uživatelské rozhraní uzlu', toggle_node_ui: 'Uživatelské rozhraní uzlu',
+1 -1
View File
@@ -342,7 +342,7 @@ window.localisation.de = {
node_balance: 'Kontostand: {balance} Sats', node_balance: 'Kontostand: {balance} Sats',
lnbits_balance: 'LNbits-Guthaben: {balance} Sats', lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
funding_reserve_percent: 'Reservieren Prozent: {percent} %', funding_reserve_percent: 'Reservieren Prozent: {percent} %',
node_managment: 'Knotenverwaltung', node_management: 'Knotenverwaltung',
node_management_not_supported: node_management_not_supported:
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt', 'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
toggle_node_ui: 'Node-Benutzeroberfläche', toggle_node_ui: 'Node-Benutzeroberfläche',
+70 -6
View File
@@ -22,6 +22,7 @@ window.localisation.en = {
active_channels: 'Active Channels', active_channels: 'Active Channels',
connect_peer: 'Connect Peer', connect_peer: 'Connect Peer',
connect: 'Connect', connect: 'Connect',
reconnect: 'Reconnect',
open_channel: 'Open Channel', open_channel: 'Open Channel',
open: 'Open', open: 'Open',
close_channel: 'Close Channel', close_channel: 'Close Channel',
@@ -51,6 +52,7 @@ window.localisation.en = {
wallet: 'Wallet: ', wallet: 'Wallet: ',
wallet_name: 'Wallet name', wallet_name: 'Wallet name',
wallets: 'Wallets', wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet', add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet', add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet', pin_wallet: 'Pin wallet',
@@ -60,6 +62,7 @@ window.localisation.en = {
rename_wallet: 'Rename wallet', rename_wallet: 'Rename wallet',
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking', fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers',
currency: 'Currency', currency: 'Currency',
update_currency: 'Update currency', update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
@@ -99,6 +102,11 @@ window.localisation.en = {
memo: 'Memo', memo: 'Memo',
date: 'Date', date: 'Date',
path: 'Path', path: 'Path',
internal_memo: 'Internal memo (optional)',
internal_memo_hint_receive:
"This memo is not shown to the payer but it's stored in the invoice for your reference.",
internal_memo_hint_pay:
"This memo is not shown to the payee but it's stored in the payment for your reference.",
payment_processing: 'Processing payment...', payment_processing: 'Processing payment...',
payment_processing: 'Processing payment...', payment_processing: 'Processing payment...',
payment_successful: 'Payment successful!', payment_successful: 'Payment successful!',
@@ -141,6 +149,7 @@ window.localisation.en = {
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
enabled: 'Enabled',
pay_to_enable: 'Pay To Enable', pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
@@ -168,15 +177,46 @@ window.localisation.en = {
extension_required_lnbits_version: 'This release requires LNbits version', extension_required_lnbits_version: 'This release requires LNbits version',
min_version: 'Minimum (included)', min_version: 'Minimum (included)',
max_version: 'Maximum (excluded)', max_version: 'Maximum (excluded)',
preimage: 'Preimage',
preimage_hint: 'Preimage to settle the hold invoice',
hold_invoice: 'Hold Invoice',
hold_invoice_description:
'This invoice is on hold and requires a preimage to settle.',
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
invoice_cancelled: 'Invoice Cancelled',
invoice_settled: 'Invoice Settled',
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
settle_invoice: 'Settle Invoice',
cancel_invoice: 'Cancel Invoice',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_limits: 'Amount Limits',
amount_sats: 'Amount (sats)', amount_sats: 'Amount (sats)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tag', tag: 'Tag',
unit: 'Unit', unit: 'Unit',
description: 'Description', description: 'Description',
expiry: 'Expiry', expiry: 'Expiry',
webhook: 'Webhook', webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@@ -190,6 +230,9 @@ window.localisation.en = {
notifications_nostr_private_key: 'Nostr Private Key', notifications_nostr_private_key: 'Nostr Private Key',
notifications_nostr_private_key_desc: notifications_nostr_private_key_desc:
'Private key (hex or nsec) to sign the messages sent to Nostr', 'Private key (hex or nsec) to sign the messages sent to Nostr',
notifications_nostr_identifier: 'Nostr Identifier',
notifications_nostr_identifier_desc:
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers', notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc: notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to', 'List of identifiers to send notifications to',
@@ -199,9 +242,10 @@ window.localisation.en = {
notifications_enable_telegram_desc: 'Send notfications over Telegram', notifications_enable_telegram_desc: 'Send notfications over Telegram',
notifications_telegram_access_token: 'Access Token', notifications_telegram_access_token: 'Access Token',
notifications_telegram_access_token_desc: 'Access token for the bot', notifications_telegram_access_token_desc: 'Access token for the bot',
notifications_chat_id: 'Chat ID', notifications_chat_id: 'Telegram Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to', notifications_chat_id_desc: 'Telegram Chat ID to send the notifications to',
notifications_excluded_wallets_desc:
'Do not send notifications for these wallets',
notifications_email_config: 'Email Configuration', notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email', notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notifications over email', notifications_enable_email_desc: 'Send notifications over email',
@@ -350,12 +394,15 @@ window.localisation.en = {
update_account: 'Update Account', update_account: 'Update Account',
invalid_username: 'Invalid Username', invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider', auth_provider: 'Auth Provider',
external_id: 'External ID',
my_account: 'My Account', my_account: 'My Account',
existing_account_question: 'Already have an account?', existing_account_question: 'Already have an account?',
background_image: 'Background Image', background_image: 'Background Image',
back: 'Back', back: 'Back',
logout: 'Logout', logout: 'Logout',
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@@ -373,6 +420,8 @@ window.localisation.en = {
extension_paid_sats: 'You have already paid {paid_sats} sats.', extension_paid_sats: 'You have already paid {paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}',
select_payment_provider: 'Select payment provider',
wallet_required: 'Wallet *', wallet_required: 'Wallet *',
show_qr: 'Show QR', show_qr: 'Show QR',
retry_install: 'Retry Install', retry_install: 'Retry Install',
@@ -385,6 +434,9 @@ window.localisation.en = {
'The {name} extension requires a payment of minimum {amount} sats to enable.', 'The {name} extension requires a payment of minimum {amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets', hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck', recheck: 'Recheck',
check: 'Check',
check_connection: 'Check Connection',
check_webhook: 'Check Webhook',
contributors: 'Contributors', contributors: 'Contributors',
license: 'License', license: 'License',
reset_key: 'Reset Key', reset_key: 'Reset Key',
@@ -446,7 +498,7 @@ window.localisation.en = {
node_balance: 'Node Balance: {balance} sats', node_balance: 'Node Balance: {balance} sats',
lnbits_balance: 'LNbits Balance: {balance} sats', lnbits_balance: 'LNbits Balance: {balance} sats',
funding_reserve_percent: 'Reserve Percent: {percent} %', funding_reserve_percent: 'Reserve Percent: {percent} %',
node_managment: 'Node Management', node_management: 'Node Management',
node_management_not_supported: node_management_not_supported:
'Node Management not supported by active funding source', 'Node Management not supported by active funding source',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
@@ -491,7 +543,9 @@ window.localisation.en = {
allowed_currencies_hint: 'Limit the number of available fiat currencies', allowed_currencies_hint: 'Limit the number of available fiat currencies',
default_account_currency: 'Default Account Currency', default_account_currency: 'Default Account Currency',
default_account_currency_hint: 'Default currency for accounting', default_account_currency_hint: 'Default currency for accounting',
min_incoming_payment_amount: 'Min Incoming Payment Amount',
min_incoming_payment_amount_desc:
'Minimum amount allowed for generating an invoice',
max_incoming_payment_amount: 'Max Incoming Payment Amount', max_incoming_payment_amount: 'Max Incoming Payment Amount',
max_incoming_payment_amount_desc: max_incoming_payment_amount_desc:
'Maximum amount allowed for generating an invoice', 'Maximum amount allowed for generating an invoice',
@@ -553,6 +607,7 @@ window.localisation.en = {
admin_users_label: 'User ID', admin_users_label: 'User ID',
allowed_users: 'Allowed Users', allowed_users: 'Allowed Users',
allowed_users_hint: 'Only these users can use LNbits', allowed_users_hint: 'Only these users can use LNbits',
allowed_users_hint_feature: 'Only these users can use {feature}',
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', allow_creation_user_desc: 'Allow creation of new users on the index page',
@@ -587,5 +642,14 @@ window.localisation.en = {
view_column: 'View wallets as rows', view_column: 'View wallets as rows',
filter_payments: 'Filter payments', filter_payments: 'Filter payments',
filter_date: 'Filter by date', filter_date: 'Filter by date',
websocket_example: 'Websocket example' websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
connected: 'Connected',
not_connected: 'Not Connected'
} }
+1 -1
View File
@@ -343,7 +343,7 @@ window.localisation.es = {
node_balance: 'Balance de Nodo: {balance} sats', node_balance: 'Balance de Nodo: {balance} sats',
lnbits_balance: 'Saldo de LNbits: {balance} sats', lnbits_balance: 'Saldo de LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Porcentaje: {percent} %', funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
node_managment: 'Gestión de nodos', node_management: 'Gestión de nodos',
node_management_not_supported: node_management_not_supported:
'La gestión de nodos no es compatible con la fuente de financiación activa', 'La gestión de nodos no es compatible con la fuente de financiación activa',
toggle_node_ui: 'Interfaz de usuario de nodo', toggle_node_ui: 'Interfaz de usuario de nodo',
+65 -8
View File
@@ -22,6 +22,7 @@ window.localisation.fi = {
active_channels: 'Aktiivisia kanavia', active_channels: 'Aktiivisia kanavia',
connect_peer: 'Yhdistä naapuriin', connect_peer: 'Yhdistä naapuriin',
connect: 'Yhdistä', connect: 'Yhdistä',
reconnect: 'Uudista yhteys',
open_channel: 'Avaa kanava', open_channel: 'Avaa kanava',
open: 'Avaa', open: 'Avaa',
close_channel: 'Sulje kanava', close_channel: 'Sulje kanava',
@@ -51,14 +52,18 @@ window.localisation.fi = {
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voit lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ', 'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voit lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
access_wallet_on_mobile: 'Mobiili käyttö', access_wallet_on_mobile: 'Mobiili käyttö',
wallet: 'Lompakko:', wallet: 'Lompakko:',
wallet_name: 'Lompakon nimi',
wallets: 'Lompakot', wallets: 'Lompakot',
add_wallet: 'Lisää lompakko', add_wallet: 'Lisää lompakko',
add_new_wallet: 'Lisää uusi lompakko',
pin_wallet: 'Kiinnitä lompakko',
delete_wallet: 'Poista lompakko', delete_wallet: 'Poista lompakko',
delete_wallet_desc: delete_wallet_desc:
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!', 'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
rename_wallet: 'Nimeä lompakko uudelleen', rename_wallet: 'Nimeä lompakko uudelleen',
update_name: 'Tallenna', update_name: 'Tallenna',
fiat_tracking: 'Käytettävä valuutta', fiat_tracking: 'Käytettävä valuutta',
fiat_providers: 'Valuutan välittäjät',
currency: 'Valuutta', currency: 'Valuutta',
update_currency: 'Tallenna', update_currency: 'Tallenna',
press_to_claim: 'Lunasta varat painamalla tästä', press_to_claim: 'Lunasta varat painamalla tästä',
@@ -122,6 +127,7 @@ window.localisation.fi = {
no_extensions: 'Laajennuksia ei ole asennettu :(', no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu', created: 'Luotu',
search_extensions: 'Etsi laajennuksia', search_extensions: 'Etsi laajennuksia',
search_wallets: 'Etsi lompakkoa',
extension_sources: 'Laajennuslähteet', extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata', ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label: ext_sources_label:
@@ -134,6 +140,7 @@ window.localisation.fi = {
uninstall: 'Poista', uninstall: 'Poista',
drop_db: 'Poista tiedot', drop_db: 'Poista tiedot',
enable: 'Ota käyttöön', enable: 'Ota käyttöön',
enabled: 'Käytössä',
pay_to_enable: 'Maksa ottaaksesi käyttöön', pay_to_enable: 'Maksa ottaaksesi käyttöön',
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle', enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
disable: 'Poista käytöstä', disable: 'Poista käytöstä',
@@ -165,12 +172,33 @@ window.localisation.fi = {
payment_hash: 'Maksun tiiviste', payment_hash: 'Maksun tiiviste',
fee: 'Kulu', fee: 'Kulu',
amount: 'Määrä', amount: 'Määrä',
amount_limits: 'Määrien rajat',
amount_sats: 'Määrä (sat)', amount_sats: 'Määrä (sat)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tunniste', tag: 'Tunniste',
unit: 'Yksikkö', unit: 'Yksikkö',
description: 'Kuvaus', description: 'Kuvaus',
expiry: 'Vanhenee', expiry: 'Vanhenee',
webhook: 'Webhook', webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Maksun varmenne', payment_proof: 'Maksun varmenne',
update: 'Päivitä', update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!', update_available: 'Saatavilla on päivitys {version}-versioon!',
@@ -262,6 +290,7 @@ window.localisation.fi = {
notification_source_label: notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)', 'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'näytä lisää', more: 'näytä lisää',
more_count: 'näytä {count} lisää',
less: 'supista', less: 'supista',
releases: 'Julkaisut', releases: 'Julkaisut',
watchdog: 'Watchdog', watchdog: 'Watchdog',
@@ -276,7 +305,7 @@ window.localisation.fi = {
callback_url_rules: 'Callback URL -säännöt', callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter', enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint: 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.', 'Callback URL:it (kuten LNURL) tarkistetaan kaikkien 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_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config', wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts', wallet_charts: 'Wallet Charts',
@@ -301,6 +330,9 @@ window.localisation.fi = {
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä', login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai', or: 'tai',
create_new_wallet: 'Avaa uusi lompakko', create_new_wallet: 'Avaa uusi lompakko',
delete_all_wallets: 'Poista kaikki lompakot',
confirm_delete_all_wallets:
'Oletko todellakin varma, että haluat poistaa käyttäjältä KAIKKI lompakot?',
login_to_account: 'Kirjaudu käyttäjänimellä', login_to_account: 'Kirjaudu käyttäjänimellä',
create_account: 'Luo tili', create_account: 'Luo tili',
account_settings: 'Tilin asetukset', account_settings: 'Tilin asetukset',
@@ -309,7 +341,7 @@ window.localisation.fi = {
signin_with_nostr: 'Kirjaudu Nostr:lla', signin_with_nostr: 'Kirjaudu Nostr:lla',
signin_with_google: 'Kirjaudu Google-tunnuksella', signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella', signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella', signin_with_custom_org: 'Kirjaudu {custom_org}-palvelulla',
username_or_email: 'Käyttäjänimi tai sähköposti', username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana', password: 'Anna uusi salasana',
password_config: 'Salasanan määritys', password_config: 'Salasanan määritys',
@@ -318,7 +350,10 @@ window.localisation.fi = {
change_password: 'Vaihda salasana', change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot', update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain', update_pubkey: 'Päivitä julkinen avain',
nostr_pubkey_tooltip:
'Syötä tämän käyttäjän julkinen Nostr avain (hex arvona)',
set_password: 'Aseta salasana', set_password: 'Aseta salasana',
set_password_tooltip: 'Aseta käyttäjätunnukselle salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä', invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
invalid_password_repeat: 'Salasanat eivät täsmää', invalid_password_repeat: 'Salasanat eivät täsmää',
reset_key_generated: 'Salasanan vaihtoavain on luotu.', reset_key_generated: 'Salasanan vaihtoavain on luotu.',
@@ -327,7 +362,8 @@ window.localisation.fi = {
register: 'Rekisteröidy', register: 'Rekisteröidy',
username: 'Käyttäjänimi', username: 'Käyttäjänimi',
pubkey: 'Julkinen avain', pubkey: 'Julkinen avain',
user_id: 'Käyttäjä ID', user_id: 'Käyttäjä tunnus',
id: 'tunnus',
email: 'Sähköposti', email: 'Sähköposti',
first_name: 'Etunimi', first_name: 'Etunimi',
last_name: 'Sukunimi', last_name: 'Sukunimi',
@@ -343,6 +379,8 @@ window.localisation.fi = {
back: 'Takaisin', back: 'Takaisin',
logout: 'Poistu', logout: 'Poistu',
look_and_feel: 'Kieli ja värit', look_and_feel: 'Kieli ja värit',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@@ -354,11 +392,14 @@ window.localisation.fi = {
gradient_background: 'Gradient Background', gradient_background: 'Gradient Background',
language: 'Kieli', language: 'Kieli',
color_scheme: 'Väriteema', color_scheme: 'Väriteema',
visible_wallet_count: 'Näytettävien lompakkojen määrä',
admin_settings: 'Pääkäyttäjän asetukset', admin_settings: 'Pääkäyttäjän asetukset',
extension_cost: 'Tämä laajennus edellyttää vähintään {cost} sat maksua.', extension_cost: 'Tämä laajennus edellyttää vähintään {cost} sat maksua.',
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.', extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.', release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta', pay_from_wallet: 'Maksa lompakosta',
pay_with: 'Maksa {provider}:lla',
select_payment_provider: 'Valitse maksun välittäjä',
wallet_required: 'Lompakko *', wallet_required: 'Lompakko *',
show_qr: 'Näytä QR', show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen', retry_install: 'Yritä asennusta uudelleen',
@@ -371,6 +412,9 @@ window.localisation.fi = {
'{name} -laajennuksen aktivointi edellyttää vähintään {amount} sat maksua.', '{name} -laajennuksen aktivointi edellyttää vähintään {amount} sat maksua.',
hide_empty_wallets: 'Piilota tyhjät lompakot', hide_empty_wallets: 'Piilota tyhjät lompakot',
recheck: 'Tarkista uudelleen', recheck: 'Tarkista uudelleen',
check: 'Tarkista',
check_connection: 'Tarkista yhteys',
check_webhook: 'Tarkista Webhook',
contributors: 'Avustajat', contributors: 'Avustajat',
license: 'Lisenssi', license: 'Lisenssi',
reset_key: 'Vaihda avain', reset_key: 'Vaihda avain',
@@ -434,7 +478,7 @@ window.localisation.fi = {
node_balance: 'Solmun saldo: {balance} sats', node_balance: 'Solmun saldo: {balance} sats',
lnbits_balance: 'LNbits-saldo: {balance} sat', lnbits_balance: 'LNbits-saldo: {balance} sat',
funding_reserve_percent: 'Omavaraisuusaste: {percent} %', funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
node_managment: 'Solmun hallinta', node_management: 'Solmun hallinta',
node_management_not_supported: node_management_not_supported:
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.', 'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
toggle_node_ui: 'Solmun käyttöliittymä', toggle_node_ui: 'Solmun käyttöliittymä',
@@ -447,9 +491,9 @@ window.localisation.fi = {
fee_reserve_percent: 'Kuluvaraus prosentteina', fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat', fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina', reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika', payment_wait_time: 'Maksun odotusaika (sekuntia)',
payment_wait_time_desc: 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', 'Kuinka pitkään maksua odotetaan saapuvaksi, ennen kuin se merkitään Odotetaan-tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua, tms',
server_management: 'Palvelimen hallinta', server_management: 'Palvelimen hallinta',
base_url: 'Palvelimen URL-osoite', base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL', base_url_label: 'Palvelun staattinen pohja-URL',
@@ -474,12 +518,16 @@ window.localisation.fi = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/keycloak/token', 'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak-asiakassalasana', auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
currency_settings: 'Valuutta-asetukset', currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat', allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat', allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta', default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta', default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
min_incoming_payment_amount: 'Pienin vastaanotettava maksun määrä',
min_incoming_payment_amount_desc:
'Pienin maksun määrä jolle voi luoda laskun',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä', max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa', 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: 'Lähtevän maksun enimmäismäärä',
@@ -542,6 +590,8 @@ window.localisation.fi = {
admin_users_label: 'Käyttäjätunnus', admin_users_label: 'Käyttäjätunnus',
allowed_users: 'Sallitut käyttäjät', allowed_users: 'Sallitut käyttäjät',
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä', allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_hint_feature:
'Ainoastaan nämä käyttäjät voivat käyttää ominaisuutta {feature}',
allowed_users_label: 'Käyttäjätunnus', allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen', allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä', allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
@@ -577,5 +627,12 @@ window.localisation.fi = {
view_column: 'Näytä lompakot rinnakkain', view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja', filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä', filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket esimerkki' websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
} }
+1 -1
View File
@@ -346,7 +346,7 @@ window.localisation.fr = {
node_balance: 'Solde du nœud : {balance} sats', node_balance: 'Solde du nœud : {balance} sats',
lnbits_balance: 'Solde LNbits : {balance} sats', lnbits_balance: 'Solde LNbits : {balance} sats',
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %', funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
node_managment: 'Gestion des nœuds', node_management: 'Gestion des nœuds',
node_management_not_supported: node_management_not_supported:
"La gestion des nœuds n'est pas prise en charge par la source de financement active", "La gestion des nœuds n'est pas prise en charge par la source de financement active",
toggle_node_ui: 'Interface utilisateur de nœud', toggle_node_ui: 'Interface utilisateur de nœud',
+1 -1
View File
@@ -343,7 +343,7 @@ window.localisation.it = {
node_balance: 'Saldo Nodo: {balance} sats', node_balance: 'Saldo Nodo: {balance} sats',
lnbits_balance: 'Saldo LNbits: {balance} sats', lnbits_balance: 'Saldo LNbits: {balance} sats',
funding_reserve_percent: 'Riserva Percentuale: {percent} %', funding_reserve_percent: 'Riserva Percentuale: {percent} %',
node_managment: 'Gestione dei nodi', node_management: 'Gestione dei nodi',
node_management_not_supported: node_management_not_supported:
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.', 'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
toggle_node_ui: 'Interfaccia utente del nodo', toggle_node_ui: 'Interfaccia utente del nodo',
+1 -1
View File
@@ -334,7 +334,7 @@ window.localisation.jp = {
node_balance: 'ノード残高: {balance} サッツ', node_balance: 'ノード残高: {balance} サッツ',
lnbits_balance: 'LNbits残高: {balance} sats', lnbits_balance: 'LNbits残高: {balance} sats',
funding_reserve_percent: '予約パーセント: {percent} %', funding_reserve_percent: '予約パーセント: {percent} %',
node_managment: 'ノード管理', node_management: 'ノード管理',
node_management_not_supported: node_management_not_supported:
'アクティブな資金源ではノード管理がサポートされていません', 'アクティブな資金源ではノード管理がサポートされていません',
toggle_node_ui: 'ノードUI', toggle_node_ui: 'ノードUI',
+1 -1
View File
@@ -331,7 +331,7 @@ window.localisation.kr = {
node_balance: '노드 잔액: {balance} 사토시', node_balance: '노드 잔액: {balance} 사토시',
lnbits_balance: 'LNbits 잔액: {balance} sats', lnbits_balance: 'LNbits 잔액: {balance} sats',
funding_reserve_percent: '예약 비율: {percent} %', funding_reserve_percent: '예약 비율: {percent} %',
node_managment: '노드 관리', node_management: '노드 관리',
node_management_not_supported: node_management_not_supported:
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.', '활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
toggle_node_ui: '노드 UI', toggle_node_ui: '노드 UI',
+1 -1
View File
@@ -341,7 +341,7 @@ window.localisation.nl = {
node_balance: 'Node Balans: {balance} sats', node_balance: 'Node Balans: {balance} sats',
lnbits_balance: 'LNbits Saldo: {balance} sats', lnbits_balance: 'LNbits Saldo: {balance} sats',
funding_reserve_percent: 'Reservepercentage: {percent} %', funding_reserve_percent: 'Reservepercentage: {percent} %',
node_managment: 'Nodebeheer', node_management: 'Nodebeheer',
node_management_not_supported: node_management_not_supported:
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron', 'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
+1 -1
View File
@@ -337,7 +337,7 @@ window.localisation.pi = {
node_balance: 'Node Balance: {balance} doubloons', node_balance: 'Node Balance: {balance} doubloons',
lnbits_balance: "LNbits Balance: {balance} pieces o' eight", lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
funding_reserve_percent: 'Reserve Percent: {percent} %', funding_reserve_percent: 'Reserve Percent: {percent} %',
node_managment: 'Node Management', node_management: 'Node Management',
node_management_not_supported: node_management_not_supported:
'Node Management not be supported by active funding source', 'Node Management not be supported by active funding source',
toggle_node_ui: 'Node Main Deck', toggle_node_ui: 'Node Main Deck',
+1 -1
View File
@@ -337,7 +337,7 @@ window.localisation.pl = {
node_balance: 'Saldo węzła: {balance} sats', node_balance: 'Saldo węzła: {balance} sats',
lnbits_balance: 'Saldo LNbits: {balance} sats', lnbits_balance: 'Saldo LNbits: {balance} sats',
funding_reserve_percent: 'Rezerwa procentowa: {percent} %', funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
node_managment: 'Zarządzanie węzłami', node_management: 'Zarządzanie węzłami',
node_management_not_supported: node_management_not_supported:
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.', 'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
toggle_node_ui: 'Interfejs użytkownika węzła', toggle_node_ui: 'Interfejs użytkownika węzła',
+1 -1
View File
@@ -340,7 +340,7 @@ window.localisation.pt = {
node_balance: 'Saldo do Nó: {balance} sats', node_balance: 'Saldo do Nó: {balance} sats',
lnbits_balance: 'Saldo do LNbits: {balance} sats', lnbits_balance: 'Saldo do LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Percentagem: {percent} %', funding_reserve_percent: 'Reserve Percentagem: {percent} %',
node_managment: 'Gerenciamento de Nós', node_management: 'Gerenciamento de Nós',
node_management_not_supported: node_management_not_supported:
'Gerenciamento de nós não suportado pela fonte de financiamento ativa', 'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
toggle_node_ui: 'Interface do Usuário de Nó', toggle_node_ui: 'Interface do Usuário de Nó',
+1 -1
View File
@@ -335,7 +335,7 @@ window.localisation.sk = {
node_balance: 'Stav uzla: {balance} sats', node_balance: 'Stav uzla: {balance} sats',
lnbits_balance: 'Zostatok LNbits: {balance} sats', lnbits_balance: 'Zostatok LNbits: {balance} sats',
funding_reserve_percent: 'Rezervovať percento: {percent} %', funding_reserve_percent: 'Rezervovať percento: {percent} %',
node_managment: 'Správa uzlov', node_management: 'Správa uzlov',
node_management_not_supported: node_management_not_supported:
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania', 'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
toggle_node_ui: 'Používateľské rozhranie uzla', toggle_node_ui: 'Používateľské rozhranie uzla',
+1 -1
View File
@@ -335,7 +335,7 @@ window.localisation.we = {
node_balance: 'Cydbwysedd Nôd: {balance} sats', node_balance: 'Cydbwysedd Nôd: {balance} sats',
lnbits_balance: 'Cydbwysedd LNbits: {balance} sats', lnbits_balance: 'Cydbwysedd LNbits: {balance} sats',
funding_reserve_percent: 'Cadw Canran: {percent} %', funding_reserve_percent: 'Cadw Canran: {percent} %',
node_managment: 'Rheoli Nodau', node_management: 'Rheoli Nodau',
node_management_not_supported: node_management_not_supported:
'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol', 'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+6 -33
View File
@@ -80,6 +80,11 @@ window.AccountPageLogic = {
token_id_list: [], token_id_list: [],
allRead: false, allRead: false,
allWrite: false allWrite: false
},
notifications: {
nostr: {
identifier: ''
}
} }
} }
}, },
@@ -91,35 +96,6 @@ window.AccountPageLogic = {
window.i18n.global.locale = newValue window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue) this.$q.localStorage.set('lnbits.lang', newValue)
}, },
toggleGradient() {
this.gradientSelection = !this.gradientChoice
this.gradientChoice = this.gradientSelection
this.$q.localStorage.set('lnbits.backgroundImage', 'none')
this.applyGradient()
if (!this.gradientChoice) {
window.location.reload()
}
},
reactionChoiceFunc() {
this.$q.localStorage.set('lnbits.reactions', this.reactionSelection)
this.reactionChoice = this.reactionSelection
},
bgimageChoiceFunc() {
this.$q.localStorage.set('lnbits.backgroundImage', this.bgimageSelection)
this.bgimageChoice = this.bgimageSelection
this.applyBackgroundImage()
},
themeChoiceFunc(newValue) {
this.changeTheme(newValue)
this.setColors()
if (this.gradientChoice) {
this.applyGradient()
}
if (this.bgimageChoice) {
this.applyBackgroundImage()
}
this.applyBorder()
},
async updateAccount() { async updateAccount() {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
@@ -425,11 +401,8 @@ window.AccountPageLogic = {
} }
} }
}, },
async created() { async created() {
this.borderSelection = this.borderChoice
this.reactionSelection = this.reactionChoice
this.bgimageSelection = this.bgimageChoice
this.themeSelection = this.themeChoice
try { try {
const {data} = await LNbits.api.getAuthenticatedUser() const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data this.user = data
+32
View File
@@ -54,6 +54,7 @@ window.AdminPageLogic = {
chartReady: false, chartReady: false,
formAddAdmin: '', formAddAdmin: '',
formAddUser: '', formAddUser: '',
formAddStripeUser: '',
hideInputToggle: true, hideInputToggle: true,
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
@@ -187,6 +188,23 @@ window.AdminPageLogic = {
let allowed_users = this.formData.lnbits_allowed_users let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user) this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
}, },
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
addUser.length &&
!this.formData.stripe_limits.allowed_users.includes(addUser)
) {
this.formData.stripe_limits.allowed_users = [
...this.formData.stripe_limits.allowed_users,
addUser
]
this.formAddStripeUser = ''
}
},
removeStripeAllowedUser(user) {
this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
},
addIncludePath() { addIncludePath() {
if (!this.formAddIncludePath) { if (!this.formAddIncludePath) {
return return
@@ -613,6 +631,20 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
}, },
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
.then(response => {
response
const data = response.data
Quasar.Notify.create({
type: data.success ? 'positive' : 'warning',
message: data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
downloadBackup() { downloadBackup() {
window.open('/admin/api/v1/backup', '_blank') window.open('/admin/api/v1/backup', '_blank')
}, },
+71 -173
View File
@@ -19,43 +19,47 @@ window.LNbits = {
amount, amount,
memo, memo,
unit = 'sat', unit = 'sat',
lnurlCallback = null lnurlWithdraw = null,
fiatProvider = null,
internalMemo = null,
payment_hash = null
) { ) {
return this.request('post', '/api/v1/payments', wallet.inkey, { const data = {
out: false, out: false,
amount: amount, amount: amount,
memo: memo, memo: memo,
lnurl_callback: lnurlCallback, unit: unit,
unit: unit lnurl_withdraw: lnurlWithdraw,
}) fiat_provider: fiatProvider,
payment_hash: payment_hash
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.inkey, data)
}, },
payInvoice(wallet, bolt11) { payInvoice(wallet, bolt11, internalMemo = null) {
return this.request('post', '/api/v1/payments', wallet.adminkey, { const data = {
out: true, out: true,
bolt11: bolt11 bolt11: bolt11
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
}) })
}, },
payLnurl( settleInvoice(wallet, preimage) {
wallet, return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
callback, preimage: preimage
description_hash,
amount,
description = '',
comment = '',
unit = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
amount,
comment,
description,
unit
})
},
authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
}) })
}, },
createAccount(name) { createAccount(name) {
@@ -197,6 +201,7 @@ window.LNbits = {
email: data.email, email: data.email,
extensions: data.extensions, extensions: data.extensions,
wallets: data.wallets, wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user, super_user: data.super_user,
extra: data.extra ?? {} extra: data.extra ?? {}
} }
@@ -434,18 +439,6 @@ window.LNbits = {
converter.setFlavor('github') converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true) converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text) return converter.makeHtml(text)
},
hexToRgb(hex) {
return Quasar.colors.hexToRgb(hex)
},
hexDarken(hex, percent) {
return Quasar.colors.lighten(hex, percent)
},
hexAlpha(hex, alpha) {
return Quasar.colors.changeAlpha(hex, alpha)
},
getPaletteColor(color) {
return Quasar.colors.getPaletteColor(color)
} }
} }
} }
@@ -462,7 +455,6 @@ if (!window.g) {
fiatTracking: false, fiatTracking: false,
wallets: [], wallets: [],
payments: [], payments: [],
allowedThemes: null,
langs: [], langs: [],
walletEventListeners: [], walletEventListeners: [],
updatePayments: false, updatePayments: false,
@@ -479,12 +471,13 @@ window.windowMixin = {
mobileSimple: true, mobileSimple: true,
walletFlip: true, walletFlip: true,
showAddWalletDialog: {show: false}, showAddWalletDialog: {show: false},
borderSelection: null, isUserAuthorized: false,
gradientSelection: null, isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
themeSelection: null, allowedThemes: WINDOW_SETTINGS['LNBITS_THEME_OPTIONS'],
reactionSelection: null, walletEventListeners: [],
bgimageSelection: null, darkChoice: this.$q.localStorage.has('lnbits.darkMode')
gradientSelection: false, ? this.$q.localStorage.getItem('lnbits.darkMode')
: true,
borderChoice: this.$q.localStorage.has('lnbits.border') borderChoice: this.$q.localStorage.has('lnbits.border')
? this.$q.localStorage.getItem('lnbits.border') ? this.$q.localStorage.getItem('lnbits.border')
: USE_DEFAULT_BORDER, : USE_DEFAULT_BORDER,
@@ -500,10 +493,6 @@ window.windowMixin = {
bgimageChoice: this.$q.localStorage.has('lnbits.backgroundImage') bgimageChoice: this.$q.localStorage.has('lnbits.backgroundImage')
? this.$q.localStorage.getItem('lnbits.backgroundImage') ? this.$q.localStorage.getItem('lnbits.backgroundImage')
: USE_DEFAULT_BGIMAGE, : USE_DEFAULT_BGIMAGE,
isUserAuthorized: false,
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
walletEventListeners: [],
backgroundImage: '',
...WINDOW_SETTINGS ...WINDOW_SETTINGS
} }
}, },
@@ -609,138 +598,53 @@ window.windowMixin = {
}, },
changeTheme(newValue) { changeTheme(newValue) {
document.body.setAttribute('data-theme', newValue) document.body.setAttribute('data-theme', newValue)
if (this.themeSelection) { this.$q.localStorage.set('lnbits.theme', newValue)
this.themeChoice = newValue this.themeChoice = newValue
this.$q.localStorage.set('lnbits.theme', newValue)
}
this.setColors()
}, },
applyGradient() { applyGradient() {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
if (this.gradientChoice) { if (this.gradientChoice) {
document.body.classList.add('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', true) this.$q.localStorage.set('lnbits.gradientBg', true)
// Ensure dark mode is enabled when gradient background is applied
if (!this.$q.dark.isActive) { if (!this.$q.dark.isActive) {
this.$q.dark.toggle() this.toggleDarkMode()
this.$q.localStorage.set('lnbits.darkMode', true)
} }
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important; backdrop-filter: blur(6px);`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.themeChoice}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.themeChoice}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.themeChoice}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
} else { } else {
document.body.classList.remove('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', false) this.$q.localStorage.set('lnbits.gradientBg', false)
} }
}, },
toggleDarkMode() {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive) {
this.bgimageSelection = 'null'
this.$q.localStorage.set('lnbits.gradientBg', false)
this.$q.localStorage.set('lnbits.backgroundImage', 'null')
window.location.hash = '#theme'
window.location.reload()
}
},
applyBackgroundImage() { applyBackgroundImage() {
if (this.bgimageSelection) { if (this.bgimageChoice == 'null') this.bgimageChoice = ''
this.$q.localStorage.set( if (this.bgimageChoice == '') {
'lnbits.backgroundImage', document.body.classList.remove('bg-image')
this.bgimageSelection } else {
document.body.classList.add('bg-image')
document.body.style.setProperty(
'--background',
`url(${this.bgimageChoice})`
) )
this.bgimageChoice = this.bgimageSelection
}
if (
this.bgimageChoice &&
this.bgimageChoice !== 'null' &&
this.bgimageChoice !== 'none' &&
this.bgimageChoice !== ''
) {
this.gradientChoice = true
this.applyGradient()
const style = document.createElement('style')
style.innerHTML = `
body[data-theme="${this.themeChoice}"]::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url(${this.bgimageChoice});
background-size: cover;
filter: blur(8px);
z-index: -1;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
body[data-theme="${this.themeChoice}"] .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}`
document.head.appendChild(style)
} }
this.$q.localStorage.set('lnbits.backgroundImage', this.bgimageChoice)
}, },
applyBorder() { applyBorder() {
if (this.borderSelection) { // Remove any existing border classes
this.$q.localStorage.setItem('lnbits.border', this.borderSelection) document.body.classList.forEach(cls => {
this.borderChoice = this.$q.localStorage.getItem('lnbits.border') if (cls.endsWith('-border')) {
} document.body.classList.remove(cls)
let borderStyleCSS
if (this.borderChoice == 'hard-border') {
borderStyleCSS = `box-shadow: 0 0 0 1px rgba(0,0,0,.12), 0 0 0 1px #ffffff47; border: none;`
}
if (this.borderChoice == 'neon-border') {
borderStyleCSS = `border: 2px solid ${this.$q.localStorage.getItem('lnbits.primaryColor')}; box-shadow: none;`
}
if (this.borderChoice == 'no-border') {
borderStyleCSS = `box-shadow: none; border: none;`
}
if (this.borderChoice == 'retro-border') {
borderStyleCSS = `border: none; border-color: rgba(255, 255, 255, 0.28); box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);`
}
let style = document.createElement('style')
style.innerHTML = `
body[data-theme="${this.themeChoice}"] .q-card,
body[data-theme="${this.themeChoice}"] .q-card.q-card--dark,
body[data-theme="${this.themeChoice}"] .q-date,
body[data-theme="${this.themeChoice}"] .q-date--dark {
${borderStyleCSS}
} }
` })
document.head.appendChild(style) this.$q.localStorage.setItem('lnbits.border', this.borderChoice)
document.body.classList.add(this.borderChoice)
}, },
setColors() { toggleDarkMode() {
this.$q.localStorage.set( this.$q.dark.toggle()
'lnbits.primaryColor', this.darkChoice = this.$q.dark.isActive
LNbits.utils.getPaletteColor('primary') this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
) if (!this.$q.dark.isActive) {
this.$q.localStorage.set( this.gradientChoice = false
'lnbits.secondaryColor', this.applyGradient()
LNbits.utils.getPaletteColor('secondary') }
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
document.documentElement.style.setProperty(
'--q-primary',
LNbits.utils.getPaletteColor('primary')
)
document.documentElement.style.setProperty(
'--q-secondary',
LNbits.utils.getPaletteColor('secondary')
)
}, },
copyText(text, message, position) { copyText(text, message, position) {
Quasar.copyToClipboard(text).then(() => { Quasar.copyToClipboard(text).then(() => {
@@ -805,10 +709,7 @@ window.windowMixin = {
const gradient = params.get('gradient') const gradient = params.get('gradient')
const border = params.get('border') const border = params.get('border')
if ( if (theme && this.allowedThemes.includes(theme.trim().toLowerCase())) {
theme &&
this.g.allowedThemes.includes(theme.trim().toLowerCase())
) {
const normalizedTheme = theme.trim().toLowerCase() const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme) document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme) this.$q.localStorage.set('lnbits.theme', normalizedTheme)
@@ -838,8 +739,6 @@ window.windowMixin = {
window.history.replaceState(null, null, url.pathname) window.history.replaceState(null, null, url.pathname)
} }
this.setColors()
}, },
refreshRoute() { refreshRoute() {
const path = window.location.pathname const path = window.location.pathname
@@ -851,7 +750,6 @@ window.windowMixin = {
} }
}, },
async created() { async created() {
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
this.$q.dark.set( this.$q.dark.set(
this.$q.localStorage.has('lnbits.darkMode') this.$q.localStorage.has('lnbits.darkMode')
? this.$q.localStorage.getItem('lnbits.darkMode') ? this.$q.localStorage.getItem('lnbits.darkMode')
@@ -48,6 +48,22 @@ window.app.component('lnbits-funding-sources', {
lnbits_denomination: '"sats" or 3 Letter Custom Denomination' lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
} }
], ],
[
'CLNRestWallet',
'Core Lightning Rest (plugin)',
{
clnrest_url: 'Endpoint',
clnrest_ca: 'ca.pem',
clnrest_cert: 'server.pem',
clnrest_readonly_rune: 'Rune used for readonly requests',
clnrest_invoice_rune: 'Rune used for creating invoices',
clnrest_pay_rune: 'Rune used for paying invoices using pay',
clnrest_renepay_rune: 'Rune used for paying invoices using renepay',
clnrest_last_pay_index:
'Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.',
clnrest_nodeid: 'Node id'
}
],
[ [
'CoreLightningWallet', 'CoreLightningWallet',
'Core Lightning', 'Core Lightning',
@@ -58,7 +74,7 @@ window.app.component('lnbits-funding-sources', {
], ],
[ [
'CoreLightningRestWallet', 'CoreLightningRestWallet',
'Core Lightning Rest', 'Core Lightning Rest (legacy)',
{ {
corelightning_rest_url: 'Endpoint', corelightning_rest_url: 'Endpoint',
corelightning_rest_cert: 'Certificate', corelightning_rest_cert: 'Certificate',
@@ -219,6 +235,16 @@ window.app.component('lnbits-funding-sources', {
strike_api_endpoint: 'API Endpoint', strike_api_endpoint: 'API Endpoint',
strike_api_key: 'API Key' strike_api_key: 'API Key'
} }
],
[
'BreezLiquidSdkWallet',
'Breez Liquid SDK',
{
breez_liquid_api_key: 'Breez API Key (can be empty)',
breez_liquid_seed: 'Liquid seed phrase',
breez_liquid_fee_offset_sat:
'Offset amount in sats to increase fee limit'
}
] ]
] ]
} }
@@ -118,7 +118,13 @@ window.app.component('payment-list', {
field: row => row.extra.wallet_fiat_amount field: row => row.extra.wallet_fiat_amount
} }
], ],
preimage: null,
loading: false loading: false
},
hodlInvoice: {
show: false,
payment: null,
preimage: null
} }
} }
}, },
@@ -223,6 +229,35 @@ window.app.component('payment-list', {
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
showHoldInvoiceDialog(payment) {
this.hodlInvoice.show = true
this.hodlInvoice.preimage = ''
this.hodlInvoice.payment = payment
},
cancelHoldInvoice(payment_hash) {
LNbits.api
.cancelInvoice(this.g.wallet, payment_hash)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_cancelled')
})
})
.catch(LNbits.utils.notifyApiError)
},
settleHoldInvoice(preimage) {
LNbits.api
.settleInvoice(this.g.wallet, preimage)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_settled')
})
})
.catch(LNbits.utils.notifyApiError)
},
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
+17 -3
View File
@@ -8,7 +8,8 @@ window.PaymentsPageLogic = {
searchData: { searchData: {
wallet_id: null, wallet_id: null,
payment_hash: null, payment_hash: null,
memo: null memo: null,
internal_memo: null
}, },
statusFilters: { statusFilters: {
success: true, success: true,
@@ -60,10 +61,10 @@ window.PaymentsPageLogic = {
sortable: false sortable: false
}, },
{ {
name: 'fee', name: 'fee_sats',
align: 'left', align: 'left',
label: 'Fee', label: 'Fee',
field: 'fee', field: 'fee_sats',
sortable: true sortable: true
}, },
@@ -82,6 +83,14 @@ window.PaymentsPageLogic = {
sortable: false, sortable: false,
max_length: 20 max_length: 20
}, },
{
name: 'internal_memo',
align: 'left',
label: 'Internal Memo',
field: 'internal_memo',
sortable: false,
max_length: 20
},
{ {
name: 'wallet_id', name: 'wallet_id',
align: 'left', align: 'left',
@@ -166,6 +175,11 @@ window.PaymentsPageLogic = {
p.extra.wallet_fiat_currency p.extra.wallet_fiat_currency
) )
} }
if (p.extra?.internal_memo) {
p.internal_memo = p.extra.internal_memo
}
p.fee_sats =
new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats'
return p return p
}) })
+100 -116
View File
@@ -14,6 +14,7 @@ window.WalletPageLogic = {
request: '', request: '',
amount: 0, amount: 0,
comment: '', comment: '',
internalMemo: null,
unit: 'sat' unit: 'sat'
}, },
paymentChecker: null, paymentChecker: null,
@@ -35,9 +36,12 @@ window.WalletPageLogic = {
lnurl: null, lnurl: null,
units: ['sat'], units: ['sat'],
unit: 'sat', unit: 'sat',
fiatProvider: '',
data: { data: {
amount: null, amount: null,
memo: '' memo: '',
internalMemo: null,
payment_hash: null
} }
}, },
invoiceQrCode: '', invoiceQrCode: '',
@@ -114,8 +118,6 @@ window.WalletPageLogic = {
isFiatPriority: false, isFiatPriority: false,
formattedFiatAmount: 0, formattedFiatAmount: 0,
formattedExchange: null, formattedExchange: null,
primaryColor: this.$q.localStorage.getItem('lnbits.primaryColor'),
secondaryColor: this.$q.localStorage.getItem('lnbits.secondaryColor'),
chartData: [], chartData: [],
chartDataPointCount: 0, chartDataPointCount: 0,
chartConfig: { chartConfig: {
@@ -198,6 +200,8 @@ window.WalletPageLogic = {
this.receive.paymentHash = null this.receive.paymentHash = null
this.receive.data.amount = null this.receive.data.amount = null
this.receive.data.memo = null this.receive.data.memo = null
this.receive.data.internalMemo = null
this.receive.data.payment_hash = null
this.receive.unit = this.isFiatPriority this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat' ? this.g.wallet.currency || 'sat'
: 'sat' : 'sat'
@@ -219,6 +223,7 @@ window.WalletPageLogic = {
window.isSecureContext && navigator.clipboard?.readText !== undefined window.isSecureContext && navigator.clipboard?.readText !== undefined
this.parse.data.request = '' this.parse.data.request = ''
this.parse.data.comment = '' this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.data.paymentChecker = null this.parse.data.paymentChecker = null
this.parse.camera.show = false this.parse.camera.show = false
this.focusInput('textArea') this.focusInput('textArea')
@@ -253,38 +258,42 @@ window.WalletPageLogic = {
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo, this.receive.data.memo,
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback this.receive.lnurlWithdraw,
this.receive.fiatProvider,
this.receive.data.internalMemo,
this.receive.data.payment_hash
) )
.then(response => { .then(response => {
this.g.updatePayments = !this.g.updatePayments this.g.updatePayments = !this.g.updatePayments
this.receive.status = 'success' this.receive.status = 'success'
this.receive.paymentReq = response.data.bolt11 this.receive.paymentReq = response.data.bolt11
this.receive.fiatPaymentReq =
response.data.extra?.fiat_payment_request
this.receive.amountMsat = response.data.amount this.receive.amountMsat = response.data.amount
this.receive.paymentHash = response.data.payment_hash this.receive.paymentHash = response.data.payment_hash
if (!this.receive.lnurl) { if (!this.receive.lnurl) {
this.readNfcTag() this.readNfcTag()
} }
// TODO: lnurl_callback and lnurl_response
// WITHDRAW // WITHDRAW
if (response.data.lnurl_response !== null) { if (response.data.extra.lnurl_response !== null) {
if (response.data.lnurl_response === false) { if (response.data.extra.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect` response.data.extra.lnurl_response = `Unable to connect`
} }
const domain = this.receive.lnurl.callback.split('/')[2]
if (typeof response.data.lnurl_response === 'string') { if (typeof response.data.extra.lnurl_response === 'string') {
// failure // failure
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, message: `${domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response caption: response.data.extra.lnurl_response
}) })
return return
} else if (response.data.lnurl_response === true) { } else if (response.data.extra.lnurl_response === true) {
// success // success
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 3000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`, message: `Invoice sent to ${domain}!`,
spinner: true spinner: true
}) })
} }
@@ -340,25 +349,25 @@ window.WalletPageLogic = {
) )
.then(response => { .then(response => {
const data = response.data const data = response.data
if (data.status === 'ERROR') { if (data.status === 'ERROR') {
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: `${data.domain} lnurl call failed.`, message: `lnurl scan failed.`,
caption: data.reason caption: data.reason
}) })
return return
} }
if (data.kind === 'pay') { if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data) this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000 this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') { } else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data) this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') { } else if (data.tag === 'withdrawRequest') {
this.parse.show = false this.parse.show = false
this.receive.show = true this.receive.show = true
this.receive.lnurlWithdraw = Object.freeze(data)
this.receive.status = 'pending' this.receive.status = 'pending'
this.receive.paymentReq = null this.receive.paymentReq = null
this.receive.paymentHash = null this.receive.paymentHash = null
@@ -368,8 +377,9 @@ window.WalletPageLogic = {
data.minWithdrawable / 1000, data.minWithdrawable / 1000,
data.maxWithdrawable / 1000 data.maxWithdrawable / 1000
] ]
const domain = data.callback.split('/')[2]
this.receive.lnurl = { this.receive.lnurl = {
domain: data.domain, domain: domain,
callback: data.callback, callback: data.callback,
fixed: data.fixed fixed: data.fixed
} }
@@ -475,7 +485,11 @@ window.WalletPageLogic = {
}) })
LNbits.api LNbits.api
.payInvoice(this.g.wallet, this.parse.data.request) .payInvoice(
this.g.wallet,
this.parse.data.request,
this.parse.data.internalMemo
)
.then(response => { .then(response => {
dismissPaymentMsg() dismissPaymentMsg()
this.updatePayments = !this.updatePayments this.updatePayments = !this.updatePayments
@@ -501,85 +515,49 @@ window.WalletPageLogic = {
}) })
}, },
payLnurl() { payLnurl() {
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api LNbits.api
.payLnurl( .request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
this.g.wallet, res: this.parse.lnurlpay,
this.parse.lnurlpay.callback, unit: this.parse.data.unit,
this.parse.lnurlpay.description_hash, amount: this.parse.data.amount * 1000,
this.parse.data.amount * 1000, comment: this.parse.data.comment,
this.parse.lnurlpay.description.slice(0, 120), internalMemo: this.parse.data.internalMemo
this.parse.data.comment, })
this.parse.data.unit
)
.then(response => { .then(response => {
this.parse.show = false this.parse.show = false
if (response.data.extra.success_action) {
clearInterval(this.parse.paymentChecker) const action = JSON.parse(response.data.extra.success_action)
setTimeout(() => { switch (action.tag) {
clearInterval(this.parse.paymentChecker) case 'url':
}, 40000) Quasar.Notify.create({
this.parse.paymentChecker = setInterval(() => { message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
LNbits.api caption: action.description,
.getPayment(this.g.wallet, response.data.payment_hash) html: true,
.then(res => { type: 'positive',
if (res.data.paid) { timeout: 0,
dismissPaymentMsg() closeBtn: true
clearInterval(this.parse.paymentChecker) })
// show lnurlpay success action break
const extra = response.data.extra case 'message':
if (extra.success_action) { Quasar.Notify.create({
switch (extra.success_action.tag) { message: action.message,
case 'url': type: 'positive',
Quasar.Notify.create({ timeout: 0,
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`, closeBtn: true
caption: extra.success_action.description, })
html: true, break
type: 'positive', case 'aes':
timeout: 0, decryptLnurlPayAES(action, response.data.preimage)
closeBtn: true Quasar.Notify.create({
}) message: value,
break caption: extra.success_action.description,
case 'message': html: true,
Quasar.Notify.create({ type: 'positive',
message: extra.success_action.message, timeout: 0,
type: 'positive', closeBtn: true
timeout: 0, })
closeBtn: true }
}) }
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
break
}
}
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
}) })
}, },
authLnurl() { authLnurl() {
@@ -587,9 +565,13 @@ window.WalletPageLogic = {
timeout: 10, timeout: 10,
message: 'Performing authentication...' message: 'Performing authentication...'
}) })
LNbits.api LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback) .request(
'post',
'/api/v1/lnurlauth',
wallet.adminkey,
this.parse.lnurlauth
)
.then(_ => { .then(_ => {
dismissAuthMsg() dismissAuthMsg()
Quasar.Notify.create({ Quasar.Notify.create({
@@ -600,10 +582,9 @@ window.WalletPageLogic = {
this.parse.show = false this.parse.show = false
}) })
.catch(err => { .catch(err => {
dismissAuthMsg()
if (err.response.data.reason) { if (err.response.data.reason) {
Quasar.Notify.create({ Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`, message: `Authentication failed. ${this.parse.lnurlauth.callback} says:`,
caption: err.response.data.reason, caption: err.response.data.reason,
type: 'warning', type: 'warning',
timeout: 5000 timeout: 5000
@@ -820,6 +801,9 @@ window.WalletPageLogic = {
}, },
swapBalancePriority() { swapBalancePriority() {
this.isFiatPriority = !this.isFiatPriority this.isFiatPriority = !this.isFiatPriority
this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority) this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
}, },
handleFiatTracking() { handleFiatTracking() {
@@ -963,11 +947,11 @@ window.WalletPageLogic = {
label: 'Balance', label: 'Balance',
data: data.map(s => s.balance), data: data.map(s => s.balance),
pointStyle: false, pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.primaryColor, Quasar.colors.getPaletteColor('primary'),
0.3 0.3
), ),
borderColor: this.primaryColor, borderColor: Quasar.colors.getPaletteColor('primary'),
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.7, tension: 0.7,
@@ -977,11 +961,11 @@ window.WalletPageLogic = {
label: 'Fees', label: 'Fees',
data: data.map(s => s.fee), data: data.map(s => s.fee),
pointStyle: false, pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.secondaryColor, Quasar.colors.getPaletteColor('secondary'),
0.3 0.3
), ),
borderColor: this.secondaryColor, borderColor: Quasar.colors.getPaletteColor('secondary'),
borderWidth: 1, borderWidth: 1,
fill: true, fill: true,
tension: 0.7, tension: 0.7,
@@ -1022,8 +1006,8 @@ window.WalletPageLogic = {
label: 'Balance In', label: 'Balance In',
borderRadius: 5, borderRadius: 5,
data: data.map(s => s.balance_in), data: data.map(s => s.balance_in),
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.primaryColor, Quasar.colors.getPaletteColor('primary'),
0.3 0.3
) )
}, },
@@ -1031,8 +1015,8 @@ window.WalletPageLogic = {
label: 'Balance Out', label: 'Balance Out',
borderRadius: 5, borderRadius: 5,
data: data.map(s => s.balance_out), data: data.map(s => s.balance_out),
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.secondaryColor, Quasar.colors.getPaletteColor('secondary'),
0.3 0.3
) )
} }
@@ -1071,16 +1055,16 @@ window.WalletPageLogic = {
{ {
label: 'Payments In', label: 'Payments In',
data: data.map(s => s.count_in), data: data.map(s => s.count_in),
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.primaryColor, Quasar.colors.getPaletteColor('primary'),
0.3 0.3
) )
}, },
{ {
label: 'Payments Out', label: 'Payments Out',
data: data.map(s => -s.count_out), data: data.map(s => -s.count_out),
backgroundColor: LNbits.utils.hexAlpha( backgroundColor: Quasar.colors.changeAlpha(
this.secondaryColor, Quasar.colors.getPaletteColor('secondary'),
0.3 0.3
) )
} }
+62
View File
@@ -0,0 +1,62 @@
@use 'sass:color';
@each $theme, $colors in $themes {
body[data-theme='#{$theme}'] {
@each $name, $color in $colors {
@if $name== 'dark' {
--q-dark-page: #{$color};
}
@if $name== 'primary' {
--q-primary: #{$color};
}
@if $name== 'secondary' {
--q-secondary: #{$color};
}
@if $name== 'info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
}
}
}
}
}
body.gradient-bg {
background-image: linear-gradient(
to bottom right,
var(--q-dark-page),
#0a0a0a
);
background-attachment: fixed;
}
body.bg-image {
&::before {
content: '';
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(8px);
background-image: var(--background);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}
}
// transparent background for specific elements
body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
.q-header,
.q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
backdrop-filter: blur(6px);
}
}
+3 -127
View File
@@ -1,130 +1,6 @@
$themes: ( @import 'themes';
'classic': ( @import 'borders';
primary: #673ab7, @import 'background';
secondary: #9c27b0,
dark: #1f2234,
info: #333646,
marginal-bg: #1f2234,
marginal-text: #fff
),
'bitcoin': (
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
primary: #e22156,
secondary: #b91a45,
dark: #0a0a0a,
info: #1b1b1b,
marginal-bg: #2d293b,
marginal-text: #fff
),
'cyber': (
primary: #7cb342,
secondary: #558b2f,
dark: #0a0a0a,
info: #1b1b1b,
marginal-bg: #2d293b,
marginal-text: #fff
),
'mint': (
primary: #3ab77d,
secondary: #27b065,
dark: #1f342b,
info: #334642,
marginal-bg: #1f342b,
marginal-text: #fff
),
'autumn': (
primary: #b7763a,
secondary: #b07927,
dark: #34291f,
info: #463f33,
marginal-bg: #342a1f,
marginal-text: rgb(255, 255, 255)
),
'flamingo': (
primary: #ff00ff,
secondary: #fda3fd,
dark: #2f032f,
info: #bc23bc,
marginal-bg: #311231,
marginal-text: rgb(255, 255, 255)
),
'monochrome': (
primary: #494949,
secondary: #6b6b6b,
dark: #000,
info: rgb(39, 39, 39),
marginal-bg: #000,
marginal-text: rgb(255, 255, 255)
)
);
@each $theme, $colors in $themes {
@each $name, $color in $colors {
@if $name== 'dark' {
[data-theme='#{$theme}'] .q-drawer--dark,
body[data-theme='#{$theme}'].body--dark,
[data-theme='#{$theme}'] .q-menu--dark {
background: $color !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='#{$theme}'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
} @else {
body[data-theme='#{$theme}'].body--light {
background-color: #f4f4f4;
}
}
@if $name== 'info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
}
}
}
[data-theme='#{$theme}'] {
@each $name, $color in $colors {
.bg-#{$name} {
background: $color !important;
}
.text-#{$name} {
color: $color !important;
}
}
}
}
[data-theme='freedom'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='freedom'] .q-header {
background: #0a0a0a !important;
}
[data-theme='cyber'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='cyber'] .q-header {
background: #0a0a0a !important;
}
[data-theme='salvador'] .q-drawer--dark {
background: #242424 !important;
}
[data-theme='salvador'] .q-header {
background: #0f47af !important;
}
[v-cloak] { [v-cloak] {
display: none; display: none;

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