Compare commits

..
123 Commits
Author SHA1 Message Date
dni ⚡ 91ea747a0e rc5 2024-10-18 09:10:55 +02:00
dni ⚡ 5f32cfc053 revert fiat amounts :( 2024-10-18 09:08:00 +02:00
f5e59cdf49 refactor: just refactor (#2740)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-10-18 08:45:08 +02:00
dni ⚡ ee93d2f275 fixup! 2024-10-17 11:48:42 +02:00
dni ⚡ 6ffe2de8a9 merge tets from vlad 2024-10-17 11:45:23 +02:00
dni ⚡ 8596fc6faa fixup! 2024-10-17 10:43:05 +02:00
dni ⚡ aa98e38dfc rename to get_user_from_account 2024-10-17 10:43:04 +02:00
dni ⚡ deee7d80be refactor services
- add PaymentFiatAmount
- return Payment on api endpoints
2024-10-17 10:43:03 +02:00
dni ⚡ 33bc380594 traces for value and assertion errors 2024-10-17 10:42:29 +02:00
dni ⚡ 3a1064c841 update migrations.py 2024-10-17 10:42:28 +02:00
Vlad Stananddni ⚡ 75698bfd5c fix: json stringify 2024-10-17 10:42:27 +02:00
dni ⚡ 1330290713 rc4 2024-10-17 10:42:26 +02:00
Vlad Stananddni ⚡ fe43117456 fix: init DB 2024-10-17 10:42:24 +02:00
dni ⚡ 2b869cb03f remove update_payment_status/extra/details 2024-10-17 10:42:23 +02:00
dni ⚡ 6d6a35989c add dbversion model 2024-10-17 10:40:59 +02:00
dni ⚡ b03793c196 make get_user better 2024-10-17 10:40:57 +02:00
dni ⚡ 4ca558be54 clean init admin settings 2024-10-17 10:40:56 +02:00
dni ⚡ 578e031b17 vlad 2024-10-17 10:40:55 +02:00
dni ⚡ 4a102e2e85 fix qrscanner 2024-10-17 10:40:54 +02:00
dni ⚡ ff6d53afcb fix dynamic chips 2024-10-17 10:40:52 +02:00
dni ⚡ 5f328539ff qrcode 2024-10-17 10:40:51 +02:00
dni ⚡ 60d5222ea6 fix extra null 2024-10-17 10:40:50 +02:00
dni ⚡ a42e4a7175 fix where 2024-10-17 10:40:48 +02:00
dni ⚡ fe8bd4ef53 rc3 2024-10-17 10:40:47 +02:00
Tiago Vasconcelosanddni ⚡ 602980f53b added thi one also DNI
remove it if not needed
2024-10-17 10:40:46 +02:00
Tiago Vasconcelosanddni ⚡ 1eb991442f attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-17 10:40:45 +02:00
Tiago Vasconcelosanddni ⚡ fa3e1088ce screwed up and made more css pushes to this branch 2024-10-17 10:40:43 +02:00
Tiago Vasconcelosanddni ⚡ 3748a0f632 remove shadow on cards in dark theme 2024-10-17 10:40:42 +02:00
dni ⚡ 88058fa69b fix dynamic fields and ext db migration 2024-10-17 10:40:41 +02:00
Vlad Stananddni ⚡ 26d557e06c fix: component name 2024-10-17 10:40:38 +02:00
Vlad Stananddni ⚡ dd51aeb91d chore: code format 2024-10-17 10:40:37 +02:00
Vlad Stananddni ⚡ 0105e67989 fix: payment extra 2024-10-17 10:40:36 +02:00
Vlad Stananddni ⚡ 7dd1cb44a9 fix: payment extra 2024-10-17 10:40:35 +02:00
Vlad Stananddni ⚡ 54f96c3fea chore: code format 2024-10-17 10:40:33 +02:00
Vlad Stananddni ⚡ 06a686a2f6 fix: payment extra and payment hash 2024-10-17 10:40:32 +02:00
Vlad Stananddni ⚡ e4416878c2 chore: log message fix 2024-10-17 10:40:31 +02:00
dni ⚡ f0752011e8 vue3 @input migration 2024-10-17 10:40:29 +02:00
dni ⚡ 19244de979 extension toggle vue3 2024-10-17 10:40:28 +02:00
dni ⚡ 7caf577d4b payments 2024-10-17 10:40:27 +02:00
Vlad Stananddni ⚡ c0500cdcb3 fix: lint 2024-10-17 10:40:26 +02:00
Vlad Stananddni ⚡ d42772357b chore: code format 2024-10-17 10:40:24 +02:00
Vlad Stananddni ⚡ a43cf0ce10 feat: add JSON custom serializer 2024-10-17 10:40:23 +02:00
Vlad Stananddni ⚡ 4147166fad fix: cast balance to int 2024-10-17 10:40:22 +02:00
Vlad Stananddni ⚡ ab5a43799b fix: time column 2024-10-17 10:40:20 +02:00
Vlad Stananddni ⚡ 31af34fac9 fix: empty currency 2024-10-17 10:40:19 +02:00
dni ⚡ b75305caad fixup! 2024-10-17 10:40:18 +02:00
dni ⚡ 6a2cd95d3c add wallet balance 2024-10-17 10:40:17 +02:00
dni ⚡ d1b0e2380b use db url 2024-10-17 10:40:15 +02:00
dni ⚡ 1f63b94a56 escape user 2024-10-17 10:40:14 +02:00
Vlad Stananddni ⚡ 4abcb8a401 chore: remove verbose key names 2024-10-17 10:40:13 +02:00
Vlad Stananddni ⚡ 9c748dda72 chore: format 2024-10-17 10:40:06 +02:00
Vlad Stananddni ⚡ 7bad0552f6 refactor: make settings a fixture 2024-10-17 10:39:57 +02:00
Vlad Stananddni ⚡ b33abddf3a fix: settings 2024-10-17 10:38:43 +02:00
Vlad Stananddni ⚡ 4aae00c4fa fix: api tests 2024-10-17 10:37:46 +02:00
Vlad Stananddni ⚡ 4361fc7cc2 fix: add back create_user_account 2024-10-17 10:37:45 +02:00
Vlad Stananddni ⚡ 376081b369 fix: some tests 2024-10-17 10:37:43 +02:00
dni ⚡ b4c57c0faf parse date from dict 2024-10-17 10:37:42 +02:00
dni ⚡ edd72fd5db add migration for view 2024-10-17 10:37:41 +02:00
dni ⚡ 5f848a2c21 fixes 2024-10-17 10:37:38 +02:00
dni ⚡ 2a928a41bf fix 2024-10-17 10:37:37 +02:00
Vlad Stananddni ⚡ 4c5bcc3084 fix: account insert 2024-10-17 10:37:36 +02:00
dni ⚡ 3457cd1f33 fixup! 2024-10-17 10:37:34 +02:00
dni ⚡ f6d87e3c36 fixup! 2024-10-17 10:37:33 +02:00
dni ⚡ a58a698918 fixup! 2024-10-17 10:37:32 +02:00
dni ⚡ 1ed581d2ac fixup migration 2024-10-17 10:37:14 +02:00
dni ⚡ be72e35914 fixup! 2024-10-17 10:37:13 +02:00
dni ⚡ 4e3c19f277 fixup! 2024-10-17 10:37:11 +02:00
dni ⚡ 65bd26736c fixup! 2024-10-17 10:37:10 +02:00
dni ⚡ 441348f1ed fix some tests 2024-10-17 10:37:08 +02:00
dni ⚡ 3cdcb8d815 fixup! 2024-10-17 10:37:06 +02:00
dni ⚡ 595e0bf070 fixup! 2024-10-17 10:37:04 +02:00
dni ⚡ ce920d7bd1 fixup! 2024-10-17 10:37:01 +02:00
dni ⚡ 38c0bfc91c fixup! 2024-10-17 10:37:00 +02:00
dni ⚡ 275a2da4ac backwards compatible 2024-10-17 10:36:59 +02:00
dni ⚡ 6e157640ee fix account overview 2024-10-17 10:36:58 +02:00
dni ⚡ 0faa53090a fix error 2024-10-17 10:36:56 +02:00
dni ⚡ 9fee6699f1 fixup! 2024-10-17 10:36:55 +02:00
dni ⚡ 1718031be2 fixup-qrcode 2024-10-17 10:36:54 +02:00
dni ⚡ f8d04d0637 some wallet ui fixes 2024-10-17 10:36:53 +02:00
dni ⚡ c001d50553 fixup! 2024-10-17 10:36:51 +02:00
dni ⚡ cc37dd35fe revert 2024-10-17 10:36:50 +02:00
dni ⚡ 88f3fb2807 fixup! 2024-10-17 10:36:49 +02:00
dni ⚡ 63419cb900 more fixes 2024-10-17 10:36:47 +02:00
dni ⚡ 3710242891 fixup! 2024-10-17 10:36:46 +02:00
dni ⚡ 61bf9a6ffb extension manager 2024-10-17 10:36:45 +02:00
dni ⚡ 56e348a786 last fixes 2024-10-17 10:36:44 +02:00
dni ⚡ 225181c969 big fx part2 2024-10-17 10:36:42 +02:00
dni ⚡ 03f2417d4d fix part1 2024-10-17 10:36:41 +02:00
Vlad Stananddni ⚡ bc0547ef07 chore: code clean-up 2024-10-17 10:36:38 +02:00
dni ⚡ 91a95b60d7 remove where from argument 2024-10-17 10:36:37 +02:00
dni ⚡ 5aa88c56b7 fixup! 2024-10-17 10:36:36 +02:00
dni ⚡ d59b3b6bfb more docs 2024-10-17 10:36:35 +02:00
dni ⚡ bdeac94d99 fixup! 2024-10-17 10:36:33 +02:00
dni ⚡ 2764fefb3d fixup! 2024-10-17 10:36:32 +02:00
dni ⚡ 42c2e5b267 fixup! 2024-10-17 10:36:31 +02:00
dni ⚡ 881329eaa6 fixup! 2024-10-17 10:36:29 +02:00
dni ⚡ 3d2ed02fb1 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-17 10:36:28 +02:00
Vlad StanandGitHub ae4eda04ba test: more payment tests (#2738)
* test: pay_invoice
2024-10-17 10:27:36 +02:00
Vlad StanandGitHub 13f2dd732f fix: await retry (#2739) 2024-10-16 11:09:45 +02:00
Pavol RusnakandGitHub 80ec9e1307 update starlette and fastapi (#2737) 2024-10-16 07:00:12 +02:00
Vlad StanandGitHub 3b503eaa8a feat: add server status endpoin (#2736) 2024-10-15 14:53:49 +03:00
dni ⚡andGitHub ecc62b0011 feat: explicitly export for extensions from __init__.py (#2669)
* feat: explicitly export for extensions from `__init__.py`

makes it clear what extensions are expected to use and also makes future
changes to the structure of core safer because extension can just depend
on ```from lnbits import require_admin_key``` for example
2024-10-14 10:35:41 +03:00
b83c2e9368 docs: fix installation guide (#2734)
Co-authored-by: Gálli Zoltán <gallizoltan@gmail.com>
2024-10-10 21:22:15 +02:00
jackstar12andGitHub 512c85592f fix(boltz): sanitize invoice description (#2731)
Boltz rejects nbsp char (produced by JS Intl.NumberFormat api), so simply replace it with normal space
2024-10-09 19:32:56 +02:00
jackstar12andGitHub 76b01695a0 fix(boltz): check for direct onchain payments (#2732) 2024-10-09 18:52:20 +02:00
jackstar12andGitHub d7065392d1 chore: add .idea folder to gitignore (#2730) 2024-10-09 19:31:50 +03:00
Vlad StanandGitHub a58deff70c feat: block pay invoice (#2727) 2024-10-01 17:17:42 +03:00
dni ⚡andGitHub e85a78854e feat: make LNBITS_ADMIN_UI the default (#2726)
* feat: make LNBITS_ADMIN_UI the default
* fix create fake admin
2024-10-01 11:36:22 +02:00
dni ⚡andGitHub a28e334c5f test: fix test warning (#2725) 2024-10-01 11:07:13 +02:00
a4c000d7dc feat: add password reset for usermanager (#2688)
* feat: add password reset for usermanager
- add a reset_key to account table
- add ?reset_key= GET arguments to index.html and show reset form if provided
- superuser can generate and copy reset url with key to share
future ideas:
- could add send forgot password email if user fill out email address
* feat: simplify reset key
* test: use reset key
* test: add more tests
* test: reset passwords do not match
* test: `reset_password_auth_threshold_expired`

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-10-01 10:59:57 +02:00
dni ⚡andGitHub 3a64cf5245 feat: add currency options to LNbits.api (#2713)
* feat: add currency options to LNbits.api
2024-09-30 14:01:30 +02:00
0b8da2b524 [feat] Nostr Login (#2703)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-30 13:53:38 +02:00
dni ⚡andGitHub f062b3d5e5 ci: do not create gh release on rc's (#2716)
* ci: do not create gh release on rc's
2024-09-30 08:51:19 +02:00
dni ⚡andGitHub b9e9da5da7 fix: fundingsources template id was wrong (#2723) 2024-09-29 22:59:13 +02:00
dni ⚡andGitHub 953a466119 fix: more vue3 q-tables and v-model:value (#2717) 2024-09-27 15:18:56 +02:00
dni ⚡andGitHub 7ad360d176 fix: vue3 i18n changed it replacements markers (#2722)
* fix: vue3 i18n changed it replacements markers
* chore: bundle
2024-09-27 14:59:06 +02:00
dni ⚡andGitHub 898f264951 refactor: use <template> for components (#2715)
* refactor: use <template> for components
* fix paymentchart
2024-09-27 12:09:47 +02:00
dni ⚡andGitHub 75bcbdc18d chore: remove used crud for webpush settings (#2719) 2024-09-27 09:35:33 +03:00
dni ⚡andGitHub 3ac6bc3c3b fix: vue3 language switcher (#2712) 2024-09-25 12:09:00 +02:00
Vlad StanandGitHub 04882e05a3 chore: update some log messages (#2710) 2024-09-25 11:22:00 +03:00
dni ⚡andGitHub cf2aca3327 fix: forgotten Vue inits (#2708) 2024-09-25 08:01:11 +03:00
dni ⚡andGitHub 1d0547ea97 fix: use Quasar.notify (#2707)
* fix: use Quasar.Notify.create
instead of this.$q, had issues when pasting invoice, using the `Quasar`
is more consistent
2024-09-24 16:18:56 +02:00
dni ⚡andGitHub 99c5f00c91 fix: extensions tab for vue3 (#2706) 2024-09-24 13:39:31 +03:00
109 changed files with 12487 additions and 3440 deletions
+3 -3
View File
@@ -9,8 +9,8 @@
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNbits"
@@ -140,7 +140,7 @@ BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRI
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
-12
View File
@@ -10,19 +10,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --prerelease
docker:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
+3
View File
@@ -55,3 +55,6 @@ lnbits-backup.zip
# builded python package
dist
# jetbrains
.idea
+4
View File
@@ -38,6 +38,9 @@ checkeditorconfig:
dev:
poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
@@ -84,6 +87,7 @@ migration:
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
+3 -3
View File
@@ -375,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
@@ -414,7 +414,7 @@ EOF
Restart Apache2:
```sh
service restart apache2
service apache2 restart
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -468,7 +468,7 @@ EOF
Restart nginx:
```sh
service restart nginx
service nginx restart
```
## Using https without reverse proxy
+24
View File
@@ -0,0 +1,24 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
# services
"pay_invoice",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+14 -11
View File
@@ -6,7 +6,7 @@ import shutil
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -17,12 +17,10 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
add_installed_extension,
get_dbversions,
get_db_version,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.extensions.extension_manager import deactivate_extension
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task
@@ -47,7 +45,7 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.extensions.models import Extension, InstallableExtension
from .core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .middleware import (
CustomGZipMiddleware,
@@ -240,7 +238,8 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
settings.deactivate_extension_paths(ext.id)
await update_installed_extension_state(ext_id=ext.id, active=False)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -252,7 +251,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
@@ -272,8 +271,13 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
)
installed_extensions.append(ext_info)
@@ -304,14 +308,13 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
+22 -27
View File
@@ -3,8 +3,7 @@ import importlib
import time
from functools import wraps
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
from typing import Optional
import click
import httpx
@@ -18,19 +17,20 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment_status,
update_payment,
)
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import migrate_databases
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
@@ -123,7 +123,7 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_dbversions(conn))
click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets")
@@ -173,9 +173,10 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
@@ -232,7 +233,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = []
invalid_payments: list[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
@@ -278,8 +279,10 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
assert (
ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@extensions.command("update")
@@ -328,7 +331,7 @@ async def extensions_update(
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -402,7 +405,7 @@ async def extensions_install(
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -430,7 +433,7 @@ async def extensions_uninstall(
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -462,7 +465,7 @@ async def install_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
@@ -491,7 +494,7 @@ async def update_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -504,7 +507,7 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.installed_release
installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
@@ -512,7 +515,7 @@ async def update_extension(
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.installed_release.source_repo
and release.source_repo == installed_ext.meta.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
@@ -659,11 +662,3 @@ async def _is_lnbits_started(url: Optional[str]):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+247 -525
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -3,14 +3,14 @@ import importlib
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
add_installed_extension,
create_installed_extension,
delete_installed_extension,
get_dbversions,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
@@ -18,22 +18,24 @@ from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_id = ext_info.id
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_info.id)
ext_info.payments = installed_ext.payments if installed_ext else []
installed_ext = await get_installed_extension(ext_id)
if installed_ext:
ext_info.meta = installed_ext.meta
await ext_info.download_archive()
ext_info.extract_archive()
db_version = (await get_dbversions()).get(ext_info.id, 0)
await migrate_extension_database(extension, db_version)
db_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, db_version)
await add_installed_extension(ext_info)
await create_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
await stop_extension_background_work(ext_id)
return extension
@@ -75,7 +77,7 @@ async def stop_extension_background_work(ext_id: str) -> bool:
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
assert stop_fn_name, f"No stop function found for '{ext.module_name}'."
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
+86 -72
View File
@@ -109,8 +109,8 @@ class ReleasePaymentInfo(BaseModel):
class PayToEnableInfo(BaseModel):
required: Optional[bool] = False
amount: Optional[int] = None
amount: int
required: bool = False
wallet: Optional[str] = None
@@ -120,6 +120,7 @@ class UserExtensionInfo(BaseModel):
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
@@ -372,29 +373,37 @@ class ExtensionRelease(BaseModel):
return None
class ExtensionMeta(BaseModel):
installed_release: Optional[ExtensionRelease] = None
latest_release: Optional[ExtensionRelease] = None
pay_to_enable: Optional[PayToEnableInfo] = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: Optional[str] = None
featured: bool = False
class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: list[str] = []
is_admin_only: bool = False
stars: int = 0
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: list[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
meta: Optional[ExtensionMeta] = None
@property
def is_admin_only(self) -> bool:
return self.id in settings.lnbits_admin_extensions
@property
def hash(self) -> str:
if self.installed_release:
if self.installed_release.hash:
return self.installed_release.hash
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.installed_release.archive}".encode())
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@@ -432,15 +441,15 @@ class InstallableExtension(BaseModel):
@property
def installed_version(self) -> str:
if self.installed_release:
return self.installed_release.version
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.pay_to_enable:
if not self.meta or not self.meta.pay_to_enable:
return False
return self.pay_to_enable.required is True
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
@@ -448,12 +457,14 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
assert self.installed_release, "installed_release is none."
assert (
self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.installed_release.archive_url, ext_zip_file
download_url, self.meta.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
@@ -463,7 +474,11 @@ class InstallableExtension(BaseModel):
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash:
if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
@@ -497,17 +512,18 @@ class InstallableExtension(BaseModel):
self.short_description = config_json.get("short_description")
if (
self.installed_release
and self.installed_release.is_github_release
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.installed_release.source_repo, config_json.get("tile")
self.meta.installed_release.source_repo, config_json.get("tile")
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
@@ -522,64 +538,54 @@ class InstallableExtension(BaseModel):
def check_latest_version(self, release: Optional[ExtensionRelease]):
if not release:
return
if not self.latest_release:
self.latest_release = release
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.latest_release.version) < version_parse(release.version):
self.latest_release = release
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.payments if p.pay_link == pay_link),
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _restore_payment_info(self):
if not self.installed_release:
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return
if not self.installed_release.pay_link:
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
if payment_info:
self.installed_release.payment_hash = payment_info.payment_hash
self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if not self.installed_release or not self.installed_release.pay_link:
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return
payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats,
pay_link=self.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash,
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.payments.append(payment_info)
@classmethod
def from_row(cls, data: dict) -> InstallableExtension:
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("pay_to_enable"):
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
def from_rows(cls, rows: Optional[list[Any]] = None) -> list[InstallableExtension]:
if rows is None:
rows = []
return [InstallableExtension.from_row(row) for row in rows]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
@@ -593,14 +599,17 @@ class InstallableExtension(BaseModel):
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
@@ -609,13 +618,14 @@ class InstallableExtension(BaseModel):
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension(
id=e.id,
name=e.name,
archive=e.archive,
version=e.version,
short_description=e.short_description,
icon=e.icon,
dependencies=e.dependencies,
meta=meta,
)
@classmethod
@@ -636,11 +646,13 @@ class InstallableExtension(BaseModel):
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext:
existing_ext.check_latest_version(ext.latest_release)
if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release)
continue
ext.featured = ext.id in manifest.featured
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
extension_id_list += [ext.id]
@@ -654,7 +666,9 @@ class InstallableExtension(BaseModel):
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release)
ext.featured = ext.id in manifest.featured
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
extension_id_list += [e.id]
except Exception as e:
+40 -18
View File
@@ -1,47 +1,51 @@
import importlib
import re
from typing import Any
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.models import (
Extension,
)
from lnbits.core.extensions.models import InstallableExtension
from lnbits.core.models import DbVersion
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
async def migrate_extension_database(ext: Extension, current_version):
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
) from exc
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_version:
if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -88,20 +92,38 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in Extension.get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+86 -10
View File
@@ -1,3 +1,4 @@
import json
from time import time
from loguru import logger
@@ -99,9 +100,8 @@ async def m002_add_fields_to_apipayments(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = await db.fetchall("SELECT * FROM apipayments")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -211,7 +211,7 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await db.fetchall(
result = await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -222,6 +222,7 @@ async def m007_set_invoice_expiries(db):
AND time < {db.timestamp_now}
"""
)
rows = result.mappings().all()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -339,7 +340,7 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
rows = await db.fetchall(
result = await db.execute(
"""
SELECT *
FROM wallets
@@ -348,12 +349,13 @@ async def m014_set_deleted_wallets(db):
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
@@ -541,5 +543,79 @@ async def m021_add_success_failed_to_apipayments(db):
GROUP BY apipayments.wallet
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m022_add_pubkey_to_accounts(db):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db):
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
"""
)
async def m026_update_payment_table(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db):
result = await db.execute("SELECT * FROM apipayments")
payments = result.mappings().all()
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(payment.get("extra"))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
+120 -83
View File
@@ -1,19 +1,18 @@
from __future__ import annotations
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from pydantic import BaseModel, validator
from passlib.context import CryptContext
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel, FromRowModel
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -35,16 +34,21 @@ class BaseWallet(BaseModel):
balance_msat: int
class Wallet(BaseWallet):
class Wallet(BaseModel):
id: str
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property
def balance(self) -> int:
return self.balance_msat // 1000
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
@@ -68,11 +72,6 @@ class Wallet(BaseWallet):
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum):
admin = 0
@@ -90,7 +89,7 @@ class WalletTypeInfo:
wallet: Wallet
class UserConfig(BaseModel):
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -103,16 +102,43 @@ class UserConfig(BaseModel):
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
class Account(BaseModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
extra: UserExtra = UserExtra()
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
@property
def is_super_user(self) -> bool:
return self.id == settings.super_user
@property
def is_admin(self) -> bool:
return self.id in settings.lnbits_admin_users or self.is_super_user
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
@@ -127,7 +153,7 @@ class AccountFilters(FilterModel):
]
id: str
last_payment: Optional[datetime.datetime] = None
last_payment: Optional[datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
@@ -136,16 +162,17 @@ class AccountFilters(FilterModel):
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
@@ -177,15 +204,26 @@ class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
@@ -203,6 +241,13 @@ class LoginUsernamePassword(BaseModel):
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
@@ -212,36 +257,55 @@ class PaymentState(str, Enum):
return self.value
class PaymentExtra(BaseModel):
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
class PayInvoice(BaseModel):
payment_request: str
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
bolt11: str
amount_msat: int
memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime.datetime] = None
extra: Optional[dict] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(FromRowModel):
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
extra: Optional[dict]
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
tag: Optional[str] = None
extension: Optional[str] = None
time: datetime = datetime.now(timezone.utc)
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
extra: dict = {}
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
@@ -251,33 +315,6 @@ class Payment(FromRowModel):
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod
def from_row(cls, row: dict):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@@ -296,7 +333,7 @@ class Payment(FromRowModel):
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
@@ -324,11 +361,11 @@ class PaymentFilters(FilterModel):
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
@@ -336,7 +373,7 @@ class PaymentFilters(FilterModel):
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
date: datetime
income: int
spending: int
balance: int
@@ -358,10 +395,6 @@ class TinyURL(BaseModel):
wallet: str
time: float
@classmethod
def from_row(cls, row: dict):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
@@ -406,7 +439,6 @@ class CreateInvoice(BaseModel):
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
@@ -432,7 +464,7 @@ class WebPushSubscription(BaseModel):
user: str
data: str
host: str
timestamp: str
timestamp: datetime
class BalanceDelta(BaseModel):
@@ -447,3 +479,8 @@ class BalanceDelta(BaseModel):
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
+343 -343
View File
@@ -8,16 +8,16 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from bolt11.types import Bolt11
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.core.extensions.models import UserExtension
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
@@ -46,56 +46,54 @@ from lnbits.wallets.base import (
from .crud import (
check_internal,
check_internal_pending,
create_account,
create_admin_settings,
create_payment,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_user_from_account,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_admin_settings,
update_payment_details,
update_payment_status,
update_payment,
update_super_user,
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import (
Account,
BalanceDelta,
CreatePayment,
Payment,
PaymentState,
User,
UserConfig,
UserExtra,
Wallet,
)
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
conn: Optional[Connection] = None,
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
@@ -104,16 +102,15 @@ async def calculate_fiat_amounts(
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
return amount_sat, extra
return amount_sat, fiat_amounts
async def create_invoice(
@@ -129,7 +126,7 @@ async def create_invoice(
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> tuple[str, str]:
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -143,7 +140,7 @@ async def create_invoice(
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
amount, user_wallet, currency, extra
)
if settings.is_wallet_max_balance_exceeded(
@@ -176,22 +173,191 @@ async def create_invoice(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_sat * 1000,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
await create_payment(
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return invoice.payment_hash, payment_request
return payment
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
"""
# check_internal() returns the payment of the invoice we're waiting for
# (pending only)
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
amount_msat = create_payment_model.amount_msat
if (
internal_invoice.amount != abs(amount_msat)
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
await send_payment_notification(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if payment.success:
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
# status.pending fall through and try again
return payment
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(checking_id, conn=conn)
if old_payment:
return await _verify_external_payment(old_payment, conn)
create_payment_model.fee = -abs(fee_reserve_total_msat)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
funding_source = get_funding_source()
logger.debug(f"fundingsource: sending payment {checking_id}")
payment_response: PaymentResponse = await funding_source.pay_invoice(
create_payment_model.bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.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
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
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}"
)
return payment
async def pay_invoice(
@@ -201,18 +367,66 @@ async def pay_invoice(
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
) -> Payment:
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
if await is_internal_status_success(invoice.payment_hash, conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
await _credit_service_fee_wallet(payment, conn)
return payment
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection],
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, tag)
if not status.success:
raise PaymentError(status.message)
await check_wallet_limits(wallet_id, amount_msat, conn)
return wallet
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
@@ -220,279 +434,70 @@ async def pay_invoice(
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
else:
new_payment = await _create_external_payment(
temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
)
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, extra["tag"])
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}"
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: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
)
return invoice.payment_hash
return invoice
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection],
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(conn, wallet_id):
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
if not limit:
return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
@@ -676,6 +681,7 @@ def fee_reserve(amount_msat: int, internal: bool = False) -> int:
def service_fee(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
@@ -695,38 +701,33 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.inkey,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
await websocket_manager.send_data(payment.json(), wallet.inkey)
# json.dumps(
# {
# "wallet_balance": wallet.balance,
# "payment": payment,
# }
# ),
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
await internal_invoice_queue.put(payment.checking_id)
async def check_admin_settings():
@@ -760,7 +761,7 @@ async def check_admin_settings():
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
@@ -811,50 +812,53 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
if super_user:
account = await get_account(super_user)
if not account:
account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
if not account.wallets or len(account.wallets) == 0:
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if username and await get_account_by_username(username):
raise ValueError("Username already exists.")
if account:
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if email and await get_account_by_email(email):
raise ValueError("Email already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if user_id:
user_uuid4 = UUID(hex=user_id, version=4)
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
else:
user_id = uuid4().hex
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
if account.id:
user_uuid4 = UUID(hex=account.id, version=4)
assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string"
else:
account.id = uuid4().hex
account = await create_account(user_id, username, email, password, user_config)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
return account
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
return user
class WebsocketConnectionManager:
@@ -878,8 +882,8 @@ class WebsocketConnectionManager:
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
async def switch_to_voidwallet() -> None:
@@ -909,12 +913,8 @@ async def update_pending_payments(wallet_id: str):
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment)
@@ -24,6 +24,40 @@
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-md-12 col-sm-12 q-pr-sm">
<q-input
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
label="Nostr Request URL"
hint="Absolute URL that the clients will use to login."
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div>
<div>
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
></q-chip>
</div>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"
+1
View File
@@ -26,6 +26,7 @@
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
+5 -7
View File
@@ -16,14 +16,14 @@
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText('{{ wallet.id }}')"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
@@ -32,7 +32,7 @@
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
@@ -55,9 +55,7 @@
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
></em>
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
@@ -70,7 +68,7 @@
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.inkey }}')"
@click="copyText(wallet.inkey)"
></q-icon>
</div>
</q-item-section>
+66 -27
View File
@@ -26,7 +26,9 @@
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="passwordData.show">
<div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col">
@@ -36,19 +38,26 @@
</div>
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input
v-if="user.has_password"
v-model="passwordData.oldPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
@@ -57,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPassword"
v-model="credentialsData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
@@ -66,7 +75,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPasswordRepeat"
v-model="credentialsData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
@@ -75,24 +84,47 @@
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
:disable="disableUpdatePassword()"
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
v-model="credentialsData.pubkey"
type="text"
label="Pubkey"
filled
dense
></q-input>
<q-btn
@click="passwordData.show = false"
@click="updatePubkey"
unelevated
color="primary"
class="q-mt-md float-right"
:label="$t('update_pubkey')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="credentialsData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
@@ -101,9 +133,9 @@
<div class="row">
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
@@ -137,6 +169,15 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
@@ -195,9 +236,9 @@
</div>
</q-card-section>
<q-card-section v-if="user.config">
<q-card-section v-if="user.extra">
<q-input
v-model="user.config.first_name"
v-model="user.extra.first_name"
:label="$t('first_name')"
filled
dense
@@ -205,7 +246,7 @@
>
</q-input>
<q-input
v-model="user.config.last_name"
v-model="user.extra.last_name"
:label="$t('last_name')"
filled
dense
@@ -213,7 +254,7 @@
>
</q-input>
<q-input
v-model="user.config.provider"
v-model="user.extra.provider"
:label="$t('auth_provider')"
filled
dense
@@ -222,10 +263,9 @@
>
</q-input>
<q-input
v-model="user.config.picture"
v-model="user.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@@ -236,11 +276,10 @@
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
filled
color="primary"
class="float-right"
></q-btn>
</q-card-section>
@@ -431,7 +470,7 @@
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@input="reactionChoiceFunc"
@update:model-value="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
+20 -34
View File
@@ -23,26 +23,14 @@
<div class="q-pa-xs">
<div class="q-gutter-y-md">
<q-tabs
v-model="tab"
@input="handleTabChanged"
:model-value="tab"
@update:model-value="handleTabChanged"
active-color="primary"
align="left"
>
<q-tab
name="installed"
:label="$t('installed')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="all"
:label="$t('all')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="featured"
:label="$t('featured')"
@update="val => tab = val.name"
></q-tab>
<q-tab name="installed" :label="$t('installed')"></q-tab>
<q-tab name="all" :label="$t('all')"></q-tab>
<q-tab name="featured" :label="$t('featured')"></q-tab>
<i
v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')"
@@ -119,7 +107,7 @@
color="secondary"
style=""
v-model="extension.isActive"
@input="toggleExtension(extension)"
@update:model-value="toggleExtension(extension)"
><q-tooltip>
&nbsp;
<span
@@ -671,11 +659,9 @@
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div v-else class="col">
@@ -799,7 +785,7 @@
swipeable
animated
v-model="slide"
:fullscreen.sync="fullscreen"
v-model:fullscreen="fullscreen"
thumbnails
infinite
:autoplay="autoplay"
@@ -1032,7 +1018,7 @@
this.filteredExtensions = this.extensions.concat([])
this.handleTabChanged('installed')
this.tab = 'installed'
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension uninstalled!'
})
@@ -1062,7 +1048,7 @@
extension.installedRelease = null
extension.inProgress = false
extension.hasDatabaseTables = false
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension DB deleted!'
})
@@ -1072,7 +1058,7 @@
extension.inProgress = false
})
},
toggleExtension: function (extension) {
toggleExtension(extension) {
const action = extension.isActive ? 'activate' : 'deactivate'
LNbits.api
.request(
@@ -1081,7 +1067,7 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: `Extension '${extension.id}' ${action}d!`
})
@@ -1106,7 +1092,7 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension enabled!'
})
@@ -1127,7 +1113,7 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension disabled!'
})
@@ -1160,7 +1146,7 @@
}
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Payment info updated!'
})
@@ -1342,7 +1328,7 @@
ws.addEventListener('message', async ({data}) => {
const payment = JSON.parse(data)
if (payment.pending === false) {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Invoice Paid!'
})
@@ -1411,13 +1397,13 @@
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
const resp = JSON.parse(data)
if (resp.paid) {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Invoice Paid!'
})
this.installExtension(this.selectedRelease)
} else {
this.$q.notify({
Quasar.Notify.create({
type: 'warning',
message: 'Invoice tracking lost!'
})
@@ -97,9 +97,9 @@
}
</style>
<script>
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
+63 -1
View File
@@ -184,6 +184,47 @@
</div>
</q-form>
</q-card-section>
<q-card-section
v-if="authAction === 'reset' && authMethod === 'username-password'"
>
<b> <span v-text="$t('reset_password')"></span> </b><br /><br />
<q-form @submit="reset" class="q-gutter-md">
<q-input
filled
dense
required
:disable="true"
v-model="reset_key"
:label="$t('reset_key') + ' *'"
></q-input>
<q-input
filled
dense
v-model="password"
:label="$t('password') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
filled
dense
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<div>
<q-btn
unelevated
color="primary"
:disable="!password || !passwordRepeat|| !reset_key || (password !== passwordRepeat)"
type="submit"
class="full-width"
:label="$t('reset_password')"
></q-btn>
</div>
</q-form>
</q-card-section>
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
<q-card-section
v-if="authAction === 'register' && authMethod === 'user-id-only'"
@@ -230,7 +271,28 @@
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS %}
{% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
@click="signInWithNostr"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
class="bg-primary"
:src="'{{ static_url_for('static', 'images/logos/nostr.svg') }}'"
></q-img>
</q-avatar>
<div>
<span v-text="$t('signin_with_nostr')"></span>
</div>
</q-btn>
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/google"
+9 -15
View File
@@ -6,7 +6,7 @@
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
<!---->
{% block page %}
<div class="row q-col-gutter-md">
@@ -38,7 +38,6 @@
<strong v-text="formattedBalance"></strong>
<small>{{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance
v-if="'{{user.super_user}}' == 'True'"
:wallet_id="this.g.wallet.id"
flat
:callback="updateBalanceCallback"
@@ -154,10 +153,7 @@
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode-vue
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{ width: 256 }"
></qrcode-vue>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
@@ -370,11 +366,9 @@
>
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
@@ -627,8 +621,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@@ -651,8 +645,8 @@
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
@@ -94,9 +94,9 @@
<q-table
dense
flat
:data="paymentsTable.data"
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@@ -256,9 +256,9 @@
<q-table
dense
flat
:data="invoiceTable.data"
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
:pagination.sync="invoiceTable.pagination"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
+3 -3
View File
@@ -47,7 +47,7 @@
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
@@ -55,7 +55,7 @@
msg: 'hello'
}
},
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
@@ -319,7 +319,7 @@
.confirmDialog('Do you really wanna disconnect this peer?')
.onOk(() => {
this.api('DELETE', `/peers/${id}`).then(response => {
this.$q.notify({
Quasar.Notify.create({
message: 'Disconnected',
icon: null
})
+2 -5
View File
@@ -53,10 +53,7 @@ context %} {% block page %}
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
@@ -64,7 +61,7 @@ context %} {% block page %}
msg: 'hello'
}
},
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
@@ -6,7 +6,7 @@
<payment-list :wallet="activeWallet" />
</q-card>
</q-dialog>
<q-table :data="wallets" :columns="walletTable.columns">
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
@@ -37,6 +37,7 @@
icon="content_copy"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
@@ -45,6 +46,7 @@
v-if="!props.row.deleted"
:wallet_id="props.row.id"
:callback="topupCallback"
class="q-ml-xs"
></lnbits-update-balance>
<q-btn
round
@@ -52,6 +54,7 @@
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
@@ -62,6 +65,7 @@
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
@@ -72,6 +76,7 @@
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
@@ -81,6 +86,7 @@
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
+18 -2
View File
@@ -27,7 +27,7 @@ include "users/_createWalletDialog.html" %}
row-key="id"
:rows="users"
:columns="usersTable.columns"
:pagination.sync="usersTable.pagination"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@@ -61,6 +61,7 @@ include "users/_createWalletDialog.html" %}
icon="content_copy"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
@@ -71,6 +72,7 @@ include "users/_createWalletDialog.html" %}
icon="build"
size="sm"
:color="props.row.is_admin ? 'primary' : 'grey'"
class="q-ml-xs"
@click="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
@@ -81,14 +83,25 @@ include "users/_createWalletDialog.html" %}
icon="build"
size="sm"
color="positive"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
<q-btn
round
icon="refresh"
size="sm"
color="secondary"
@click="resetPassword(props.row.id)"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUser(props.row.id, props)"
>
<q-tooltip>Delete User</q-tooltip>
@@ -102,7 +115,10 @@ include "users/_createWalletDialog.html" %}
<q-td auto-width v-text="props.row.transaction_count"></q-td>
<q-td auto-width v-text="props.row.username"></q-td>
<q-td auto-width v-text="props.row.email"></q-td>
<q-td auto-width v-text="props.row.last_payment"></q-td>
<q-td
auto-width
v-text="formatDate(props.row.last_payment)"
></q-td>
</q-tr>
</template>
</q-table>
+46 -22
View File
@@ -2,7 +2,8 @@ import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
@@ -12,8 +13,9 @@ from fastapi import (
Depends,
)
from fastapi.exceptions import HTTPException
from starlette.responses import StreamingResponse
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.models import (
BaseWallet,
ConversionData,
@@ -36,39 +38,61 @@ from lnbits.utils.exchange_rates import (
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
from .payment_api import api_payment, pay_invoice # noqa: F401
api_router = APIRouter(tags=["Core"])
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
user = await get_user(wallet.wallet.user)
if not user:
return stat
stat["version"] = settings.version
if not user.admin:
return stat
funding_source = get_funding_source()
stat["funding_source"] = funding_source.__class__.__name__
status: StatusResponse = await funding_source.status()
stat["funding_source_error"] = status.error_message
stat["funding_source_balance_msat"] = status.balance_msat
return stat
@api_router.get(
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
)
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
return [BaseWallet(**w.dict()) for w in user.wallets]
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets
@api_router.post("/api/v1/account", response_model=Wallet)
@api_router.post("/api/v1/account")
async def api_create_account(data: CreateWallet) -> Wallet:
if not settings.new_accounts_allowed:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_user_account(wallet_name=data.name)
return account.wallets[0]
user = await create_user_account(wallet_name=data.name)
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
@@ -96,7 +120,7 @@ async def api_lnurlscan(
) from exc
# params is what will be returned to the client
params: Dict = {"domain": domain}
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
@@ -145,7 +169,7 @@ async def api_lnurlscan(
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
@@ -202,13 +226,13 @@ async def api_perform_lnurlauth(
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]:
async def api_list_currencies_available() -> list[str]:
return allowed_currencies()
+258 -139
View File
@@ -1,19 +1,18 @@
import base64
import importlib
import json
from http import HTTPStatus
from time import time
from typing import Callable, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
from lnbits.core.services import create_user_account
from lnbits.decorators import check_user_exists
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@@ -22,25 +21,30 @@ from lnbits.helpers import (
is_valid_username,
)
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import normalize_public_key, verify_event
from ..crud import (
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_user,
get_user_from_account,
update_account,
update_user_password,
verify_user_password,
)
from ..models import (
AccessTokenPayload,
Account,
CreateUser,
LoginUsernamePassword,
LoginUsr,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
UserExtra,
)
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@@ -55,41 +59,43 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
)
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email)
try:
user = await get_account_by_username_or_email(data.username)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
)
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
account = Account(
id=uuid4().hex,
pubkey=event["pubkey"],
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
return _auth_success_response(account.username or "", account.id, account.email)
@auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
try:
user = await get_user(data.usr)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"Login by 'User ID' not allowed.",
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(account.username, account.id, account.email)
@auth_router.get("/{provider}", description="SSO Provider")
@@ -99,7 +105,8 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
@@ -113,31 +120,22 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
try:
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
assert userinfo is not None
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
except HTTPException as exc:
raise exc
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.",
) from exc
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
@auth_router.post("/logout")
async def logout() -> JSONResponse:
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
response = JSONResponse({"status": "success"}, HTTPStatus.OK)
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired")
@@ -150,55 +148,124 @@ async def logout() -> JSONResponse:
async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED,
"Register by 'Username and Password' not allowed.",
)
if data.password != data.password_repeat:
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
if not data.username:
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Missing username.")
if not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
try:
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username)
account = Account(
id=uuid4().hex,
email=data.email,
username=data.username,
)
account.hash_password(data.password)
await create_user_account(account)
return _auth_success_response(account.username, account.id, account.email)
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
) from exc
@auth_router.put("/pubkey")
async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
_validate_auth_timeout(payload.auth_time)
if (
data.pubkey
and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey)
await update_account(account)
return await get_user_from_account(account)
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists)
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
assert data.user_id == user.id, "Invalid user ID."
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
assert account, "Account not found."
# old accounts do not have a password
if account.password_hash:
assert data.password_old, "Missing old password."
assert account.verify_password(data.password_old), "Invalid old password."
account.username = data.username
account.hash_password(data.password)
await update_account(account)
_user = await get_user_from_account(account)
if not _user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return _user
@auth_router.put("/reset")
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
assert data.password == data.password_repeat, "Passwords do not match."
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
try:
return await update_user_password(data)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
reset_key = base64.b64decode(data.reset_key[10:]).decode()
reset_data_json = decrypt_internal_message(reset_key)
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
raise ValueError("Invalid reset key.") from exc
assert reset_data_json, "Cannot process reset key."
action, user_id, request_time = json.loads(reset_data_json)
assert action, "Missing action."
assert user_id, "Missing user ID."
assert request_time, "Missing reset time."
_validate_auth_timeout(request_time)
account = await get_account(user_id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
account.hash_password(data.password)
await update_account(account)
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update")
@@ -206,80 +273,83 @@ async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
try:
return await update_account(user.id, data.username, None, data.config)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
) from exc
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
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)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username:
account.username = data.username
if data.email:
account.email = data.email
if data.extra:
account.extra = data.extra
await update_account(account)
return await get_user_from_account(account)
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
try:
await update_account(
user_id=settings.super_user,
username=data.username,
user_config=UserConfig(provider="lnbits"),
)
super_user = UpdateUserPassword(
user_id=settings.super_user,
password=data.password,
password_repeat=data.password_repeat,
username=data.username,
)
await update_user_password(super_user)
settings.first_install = False
return _auth_success_response(username=super_user.username)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username
account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits"
account.hash_password(data.password)
await update_account(account)
settings.first_install = False
return _auth_success_response(account.username, account.id, account.email)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email
if not email or not is_valid_email_address(email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
user_config = UserConfig(**dict(userinfo))
user_config.email_verified = True
account = await get_account_by_email(email)
if verified_user_id:
if account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/account"
if account:
user = await update_account(account.id, email=email, user_config=user_config)
account.extra = account.extra or UserExtra()
account.extra.email_verified = True
await update_account(account)
else:
if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_user_account(email=email, user_config=user_config)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
account = Account(
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
)
await create_user_account(account)
return _auth_redirect_response(redirect_path, email)
@@ -288,9 +358,10 @@ def _auth_success_response(
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
access_token = create_access_token(
data={"sub": username or "", "usr": user_id, "email": email}
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -300,7 +371,8 @@ def _auth_success_response(
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email})
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
access_token = create_access_token(data=payload.dict())
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -349,3 +421,50 @@ def _find_auth_provider_class(provider: str) -> Callable:
pass
raise ValueError(f"No SSO provider found for '{provider}'.")
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
scheme, token = auth_header.split()
if scheme.lower() != "nostr":
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Invalid value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
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}'."
return event
def _validate_auth_timeout(auth_time: Optional[int] = 0):
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds."
" Please login again or ask a new reset key!",
)
+128 -104
View File
@@ -1,7 +1,6 @@
import sys
import traceback
from http import HTTPStatus
from typing import (
List,
)
from bolt11 import decode as bolt11_decode
from fastapi import (
@@ -21,10 +20,12 @@ from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
)
from lnbits.core.models import (
@@ -38,15 +39,15 @@ from lnbits.decorators import (
)
from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_dbversions,
get_db_version,
get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_installed_extension,
update_user_extension,
update_user_extension_extra,
)
extension_router = APIRouter(
@@ -71,8 +72,13 @@ async def api_install_extension(data: CreateExtension):
)
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
id=data.ext_id,
name=data.ext_id,
version=data.version,
meta=ext_meta,
icon=release.icon,
)
try:
@@ -80,6 +86,8 @@ async def api_install_extension(data: CreateExtension):
except Exception as exc:
logger.warning(exc)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
ext_info.clean_extension_files()
detail = (
str(exc)
@@ -109,33 +117,28 @@ async def api_install_extension(data: CreateExtension):
) from exc
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
@extension_router.get("/{ext_id}/details")
async def api_extension_details(
ext_id: str,
details_link: str,
):
all_releases = await InstallableExtension.get_extension_releases(ext_id)
try:
all_releases = await InstallableExtension.get_extension_releases(ext_id)
release = next(
(r for r in all_releases if r.details_link == details_link), None
)
assert release, "Details not found for release"
release_details = await ExtensionRelease.fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
release = next((r for r in all_releases if r.details_link == details_link), None)
if not release:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"Failed to get details for extension {ext_id}.",
) from exc
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
release_details = await ExtensionRelease.fetch_release_details(details_link)
if not release_details:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch details for release",
)
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
@extension_router.put("/{ext_id}/sell")
@@ -144,22 +147,21 @@ async def api_update_pay_to_enable(
data: PayToEnableInfo,
user: User = Depends(check_admin),
) -> SimpleStatus:
try:
assert (
data.wallet in user.wallet_ids
), "Wallet does not belong to this admin user."
await update_extension_pay_to_enable(ext_id, data)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
if data.wallet not in user.wallet_ids:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
) from exc
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
)
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' not found.")
if extension.meta:
extension.meta.pay_to_enable = data
else:
extension.meta = ExtensionMeta(pay_to_enable=data)
await update_installed_extension(extension)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
@extension_router.put("/{ext_id}/enable")
@@ -176,28 +178,34 @@ async def api_enable_extension(
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)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
if user.admin or not ext.requires_payment:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
user_ext = await get_user_extension(user.id, ext_id)
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.pay_to_enable and ext.pay_to_enable.wallet
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.pay_to_enable.wallet,
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
@@ -207,10 +215,9 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
@@ -233,16 +240,15 @@ async def api_disable_extension(
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Disabeling extension: {ext_id}.")
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to disable '{ext_id}'."),
) from exc
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active:
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabeling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
@@ -298,7 +304,11 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
)
if installed_ext and ext_id in installed_ext.dependencies:
if (
installed_ext
and installed_ext.meta
and ext_id in installed_ext.meta.dependencies
):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
@@ -319,9 +329,9 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
try:
extension_releases: List[ExtensionRelease] = (
extension_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
@@ -386,45 +396,59 @@ async def get_pay_to_install_invoice(
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
):
try:
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' not found."
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
assert (
ext.pay_to_enable.required
), f"Payment not required for extension '{ext_id}'."
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
f"Payment wallet or amount missing for extension '{ext_id}'."
"Please contact the administrator."
)
assert (
data.amount >= ext.pay_to_enable.amount
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
payment_hash, payment_request = await create_invoice(
wallet_id=ext.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext_info = (
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
)
user_ext_info.payment_hash_to_enable = payment_hash
await update_user_extension_extra(user.id, ext_id, user_ext_info)
return {"payment_hash": payment_hash, "payment_request": payment_request}
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
if not data.amount or data.amount <= 0:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
) from exc
status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"Extension '{ext_id}' not found."
)
if not ext.meta or not ext.meta.pay_to_enable:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment info not found for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.required:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment not required for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.wallet or not ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment wallet or amount missing for extension '{ext_id}'.",
)
if data.amount < ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Amount {data.amount} sats is less than required "
f"{ext.meta.pay_to_enable.amount} sats."
),
)
payment = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@extension_router.get(
@@ -454,7 +478,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
db_version = await get_db_version(ext_id)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+93 -93
View File
@@ -9,13 +9,12 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from loguru import logger
from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, InstallableExtension
from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.services import create_invoice
from lnbits.core.services import create_invoice, create_user_account
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -23,11 +22,11 @@ from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_account,
create_wallet,
get_dbversions,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
)
generic_router = APIRouter(
@@ -74,83 +73,87 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [
e for e in installed_exts if e.id not in installable_exts_ids
]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext:
e.installed_release = installed_ext.installed_release
if installed_ext.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
except Exception as ex:
logger.warning(ex)
installable_exts = []
installed_exts_ids = []
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
try:
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
db_version = await get_dbversions()
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.dict(),
"extensions": extensions,
},
)
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extensions": extensions,
},
)
@generic_router.get(
@@ -165,18 +168,16 @@ async def wallet(
wal: Optional[UUID4] = Query(None),
):
if wal:
wallet_id = wal.hex
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user = await get_user(user_id=user.id) or user
wallet_id = wallet.id
user.wallets.append(wallet)
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet_id = lnbits_last_active_wallet
wallet = await get_wallet(lnbits_last_active_wallet)
else:
wallet_id = user.wallets[0].id
wallet = user.wallets[0]
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
if not wallet or wallet.deleted:
return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
)
@@ -185,15 +186,16 @@ async def wallet(
request,
"core/wallet.html",
{
"user": user.dict(),
"wallet": user_wallet.dict(),
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
},
)
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
return resp
@@ -210,7 +212,7 @@ async def account(
request,
"core/account.html",
{
"user": user.dict(),
"user": user.json(),
},
)
@@ -230,11 +232,9 @@ async def service_worker(request: Request):
@generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc
user = await get_user(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return {
"short_name": settings.lnbits_site_title,
"name": settings.lnbits_site_title + " Wallet",
@@ -322,10 +322,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
request,
"node/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].dict(),
"wallets": user.wallets[0].json(),
},
)
@@ -360,7 +360,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
request,
"admin/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
@@ -377,7 +377,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
"users/index.html",
{
"request": request,
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
},
@@ -426,7 +426,7 @@ async def lnurlwallet(request: Request):
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_account()
account = await create_user_account()
wallet = await create_wallet(user_id=account.id)
_, payment_request = await create_invoice(
wallet_id=wallet.id,
+33 -66
View File
@@ -3,13 +3,12 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional, Union
from typing import List, Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
@@ -21,7 +20,6 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
@@ -121,7 +119,7 @@ async def api_payments_paginated(
return page
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
@@ -145,60 +143,42 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
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,
)
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
# 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:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
},
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
payment.extra["lnurl_response"] = resp["reason"]
else:
lnurl_response = True
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
payment.extra["lnurl_response"] = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
return payment
@payment_router.post(
@@ -220,30 +200,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoice_data: CreateInvoice = Body(...),
):
) -> Payment:
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
detail="Missing BOLT11 invoice",
)
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
elif not invoice_data.out:
# invoice key
return await api_payments_create_invoice(invoice_data, wallet.wallet)
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -269,7 +244,7 @@ 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}
@@ -313,15 +288,12 @@ async def api_payments_pay_lnurl(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
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:
@@ -330,19 +302,14 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
+81 -87
View File
@@ -1,8 +1,11 @@
import base64
import json
import time
from http import HTTPStatus
from typing import List
from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException
from fastapi.exceptions import HTTPException
from lnbits.core.crud import (
delete_account,
@@ -14,8 +17,8 @@ from lnbits.core.crud import (
update_admin_settings,
)
from lnbits.core.models import (
Account,
AccountFilters,
AccountOverview,
CreateTopup,
User,
Wallet,
@@ -23,7 +26,7 @@ from lnbits.core.models import (
from lnbits.core.services import update_wallet_balance
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import generate_filter_params_openapi
from lnbits.helpers import encrypt_internal_message, generate_filter_params_openapi
from lnbits.settings import EditableSettings, settings
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
@@ -37,106 +40,102 @@ users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_adm
)
async def api_get_users(
filters: Filters = Depends(parse_filters(AccountFilters)),
) -> Page[Account]:
try:
filtered = await get_accounts(filters=filters)
for user in filtered.data:
user.is_super_user = user.id == settings.super_user
user.is_admin = user.id in settings.lnbits_admin_users or user.is_super_user
return filtered
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch users. {exc!s}",
) from exc
) -> Page[AccountOverview]:
return await get_accounts(filters=filters)
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> None:
try:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise Exception("Cannot delete user with wallets.")
if user_id == settings.super_user:
raise Exception("Cannot delete super user.")
if user_id in settings.lnbits_admin_users and not user.super_user:
raise Exception("Only super_user can delete admin user.")
await delete_account(user_id)
except Exception as exc:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete super user.",
)
if user_id in settings.lnbits_admin_users and not user.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
await delete_account(user_id)
@users_router.put(
"/user/{user_id}/reset_password", dependencies=[Depends(check_super_user)]
)
async def api_users_reset_password(user_id: str) -> str:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Cannot change superuser password.",
)
reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json)
assert reset_key, "Cannot generate reset key."
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}"
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None:
try:
if user_id == settings.super_user:
raise Exception("Cannot change super user.")
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
except Exception as exc:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not update admin settings. {exc}",
) from exc
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot change super user.",
)
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
await update_admin_settings(update_settings)
@users_router.get("/user/{user_id}/wallet")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
try:
return await get_wallets(user_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch user wallets. {exc}",
) from exc
return await get_wallets(user_id)
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
if not wal:
raise Exception("Wallet does not exist.")
if user_id != wal.user:
raise Exception("Wallet does not belong to user.")
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
except Exception as exc:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Wallet does not belong to user.",
)
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
@users_router.delete("/user/{user_id}/wallet/{wallet}")
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
if not wal:
raise Exception("Wallet does not exist.")
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
except Exception as exc:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
@users_router.put(
@@ -146,14 +145,9 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
try:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
) from exc
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
+30 -17
View File
@@ -1,9 +1,11 @@
from http import HTTPStatus
from typing import Optional
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
)
from lnbits.core.models import (
@@ -20,6 +22,7 @@ from lnbits.decorators import (
from ..crud import (
create_wallet,
delete_wallet,
get_wallet,
update_wallet,
)
@@ -27,35 +30,45 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
res = {
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
"name": key_info.wallet.name,
"balance": key_info.wallet.balance_msat,
}
if wallet.key_type == KeyType.admin:
res["id"] = wallet.wallet.id
if key_info.key_type == KeyType.admin:
res["id"] = key_info.wallet.id
return res
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
):
await update_wallet(wallet.wallet.id, new_name)
wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name
await update_wallet(wallet)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
"id": wallet.id,
"name": wallet.name,
"balance": wallet.balance_msat,
}
@wallet_router.patch("", response_model=Wallet)
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
return await update_wallet(wallet.wallet.id, name, currency)
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = name or wallet.name
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@wallet_router.delete("")
@@ -68,9 +81,9 @@ async def api_delete_wallet(
)
@wallet_router.post("", response_model=Wallet)
@wallet_router.post("")
async def api_create_wallet(
data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
+171 -27
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
import asyncio
import datetime
import json
import os
import re
import time
from contextlib import asynccontextmanager
from enum import Enum
from typing import Any, Generic, Literal, Optional, TypeVar
from typing import Any, Generic, Literal, Optional, TypeVar, Union
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -144,29 +145,59 @@ class Connection(Compat):
clean_values[key] = raw_value
return clean_values
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().all()
result.close()
if not row:
return []
if model:
return [dict_to_model(r, model) for r in row]
return row
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().first()
result.close()
if model and row:
return dict_to_model(row, model)
return row
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
await self.conn.commit()
async def insert(self, table_name: str, model: BaseModel):
await self.conn.execute(
text(insert_query(table_name, model)), model_to_dict(model)
)
await self.conn.commit()
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
if not filters:
filters = Filters()
clause = filters.where(where)
@@ -190,11 +221,12 @@ class Connection(Compat):
{filters.pagination()}
""",
self.rewrite_values(parsed_values),
model,
)
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
result = await self.fetchone(
result = await self.execute(
f"""
SELECT COUNT(*) as count FROM (
{query}
@@ -204,14 +236,16 @@ class Connection(Compat):
""",
parsed_values,
)
count = int(result.get("count", 0))
row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
else:
count = len(rows)
else:
count = 0
return Page(
data=[model.from_row(row) for row in rows] if model else [],
data=rows,
total=count,
)
@@ -251,21 +285,19 @@ class Database(Compat):
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_timestamp(value):
def _parse_date(value) -> datetime.datetime:
if value is None:
return None
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return int(
time.mktime(datetime.datetime.strptime(value, f).timetuple())
)
return datetime.datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_timestamp,
decoder=_parse_date,
schema="pg_catalog",
)
)
@@ -296,13 +328,33 @@ class Database(Compat):
finally:
self.lock.release()
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
async with self.connect() as conn:
return await conn.fetchall(query, values)
return await conn.fetchall(query, values, model)
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
async with self.connect() as conn:
return await conn.fetchone(query, values)
return await conn.fetchone(query, values, model)
async def insert(self, table_name: str, model: BaseModel) -> None:
async with self.connect() as conn:
await conn.insert(table_name, model)
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> None:
async with self.connect() as conn:
await conn.update(table_name, model, where)
async def fetch_page(
self,
@@ -310,9 +362,9 @@ class Database(Compat):
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
@@ -372,12 +424,6 @@ class Operator(Enum):
raise ValueError("Unknown SQL Operator")
class FromRowModel(BaseModel):
@classmethod
def from_row(cls, row: dict):
return cls(**row)
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None
@@ -385,7 +431,6 @@ class FilterModel(BaseModel):
T = TypeVar("T")
TModel = TypeVar("TModel", bound=BaseModel)
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
@@ -518,3 +563,102 @@ class Filters(BaseModel, Generic[TFilterModel]):
if self.search and self.model:
values["search"] = f"%{self.search}%"
return values
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = []
keys = model_to_dict(model).keys()
for field in keys:
placeholders.append(get_placeholder(model, field))
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> str:
"""
Generate an update query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = :id`
"""
fields = []
for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
def model_to_dict(model: BaseModel) -> dict:
"""
Convert a Pydantic model to a dictionary with JSON-encoded nested models
private fields starting with _ are ignored
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if type(type_) is type(BaseModel) or type_ is dict:
_dict[key] = json.dumps(value)
continue
_dict[key] = value
return _dict
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null":
return None
_subdict = json.loads(value)
elif isinstance(value, dict):
_subdict = value
else:
logger.warning(f"Expected str or dict, got {type(value)}")
return None
# recursively convert nested models
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in _row.items():
if key not in model.__fields__:
logger.warning(f"Converting {key} to model `{model}`.")
continue
type_ = model.__fields__[key].type_
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
return _model
+46 -17
View File
@@ -14,11 +14,18 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_user,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
)
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
from lnbits.core.models import (
AccessTokenPayload,
Account,
KeyType,
SimpleStatus,
User,
WalletTypeInfo,
)
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
@@ -59,7 +66,7 @@ class KeyChecker(SecurityBase):
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.model: APIKey = openapi_model
self.model: APIKey = openapi_model # type: ignore
async def __call__(self, request: Request) -> WalletTypeInfo:
@@ -138,14 +145,16 @@ async def check_user_exists(
else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account or not settings.is_user_allowed(account.id):
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account.id)
assert user, "User not found for account."
user = await get_user_from_account(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
return user
@@ -162,6 +171,16 @@ async def optional_user_id(
return None
async def access_token_payload(
access_token: Annotated[Optional[str], Depends(check_access_token)],
) -> AccessTokenPayload:
if not access_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return AccessTokenPayload(**payload)
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
@@ -245,17 +264,17 @@ async def _check_user_extension_access(user_id: str, current_path: str):
)
async def _get_account_from_token(access_token):
async def _get_account_from_token(access_token) -> Optional[Account]:
try:
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
user = await _get_user_from_jwt_payload(payload)
if not user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
)
return user
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
@@ -263,3 +282,13 @@ async def _get_account_from_token(access_token):
except jwt.PyJWTError as exc:
logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
return None
+22 -16
View File
@@ -23,14 +23,6 @@ class InvoiceError(Exception):
self.status = status
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
register_http_exception_handler(app)
register_payment_error_handler(app)
register_invoice_error_handler(app)
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
@@ -63,7 +55,9 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
return None
def register_exception_handler(app: FastAPI):
def register_exception_handlers(app: FastAPI):
"""Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
@@ -74,8 +68,26 @@ def register_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"AssertionError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"ValueError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
def register_request_validation_exception_handler(app: FastAPI):
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
@@ -86,8 +98,6 @@ def register_request_validation_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
def register_http_exception_handler(app: FastAPI):
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
@@ -96,8 +106,6 @@ def register_http_exception_handler(app: FastAPI):
content={"detail": exc.detail},
)
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
logger.error(f"{exc.message}, {exc.status}")
@@ -106,8 +114,6 @@ def register_payment_error_handler(app: FastAPI):
content={"detail": exc.message, "status": exc.status},
)
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"{exc.message}, Status: {exc.status}")
+6 -37
View File
@@ -1,17 +1,15 @@
import json
import re
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, List, Optional, Type
from typing import Any, Optional, Type
import jinja2
import jwt
import shortuuid
from pydantic import BaseModel
from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
@@ -51,7 +49,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders:
additional_folders += [
@@ -175,37 +173,6 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
}
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = []
for field in model.dict().keys():
placeholders.append(get_placeholder(model, field))
fields = ", ".join(model.dict().keys())
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> str:
"""
Generate an update query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = :id`
"""
fields = []
for field in model.dict().keys():
placeholder = get_placeholder(model, field)
fields.append(f"{field} = {placeholder}")
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
def is_valid_email_address(email: str) -> bool:
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
return re.fullmatch(email_regex, email) is not None
@@ -217,7 +184,9 @@ def is_valid_username(username: str) -> bool:
def create_access_token(data: dict):
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.auth_token_expire_minutes
)
to_encode = data.copy()
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
+23 -7
View File
@@ -7,7 +7,6 @@ import json
from enum import Enum
from hashlib import sha256
from os import path
from sqlite3 import Row
from time import time
from typing import Any, Optional
@@ -429,10 +428,22 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password"
nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth"
github_auth = "github-auth"
keycloak_auth = "keycloak-auth"
@classmethod
def all(cls):
return [
AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value,
AuthMethods.nostr_auth_nip98.value,
AuthMethods.google_auth.value,
AuthMethods.github_auth.value,
AuthMethods.keycloak_auth.value,
]
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
@@ -443,11 +454,20 @@ class AuthSettings(LNbitsSettings):
AuthMethods.username_and_password.value,
]
)
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
class NostrAuthSettings(LNbitsSettings):
nostr_absolute_request_urls: list[str] = Field(
default=["http://127.0.0.1:5000", "http://localhost:5000"]
)
class GoogleAuthSettings(LNbitsSettings):
google_client_id: str = Field(default="")
google_client_secret: str = Field(default="")
@@ -475,6 +495,7 @@ class EditableSettings(
WebPushSettings,
NodeUISettings,
AuthSettings,
NostrAuthSettings,
GoogleAuthSettings,
GitHubAuthSettings,
KeycloakAuthSettings,
@@ -597,7 +618,7 @@ class ReadOnlySettings(
PersistenceSettings,
SuperUserSettings,
):
lnbits_admin_ui: bool = Field(default=False)
lnbits_admin_ui: bool = Field(default=True)
@validator(
"lnbits_allowed_funding_sources",
@@ -613,11 +634,6 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> Settings:
data = dict(row)
return cls(**data)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
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
+12 -12
View File
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -530,23 +530,26 @@ video {
overflow-wrap: break-word;
}
.qrcode__wrapper canvas {
.qrcode__wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important;
max-width: 100%;
max-height: 100%;
height: 100% !important;
max-width: 350px;
}
.qrcode__image {
position: absolute;
max-width: 52px;
width: 15%;
height: 15%;
overflow: hidden;
background: #fff;
left: 50%;
overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem;
border-radius: 0.2rem;
}
+10 -11
View File
@@ -35,9 +35,9 @@ window.localisation.br = {
reset_defaults_tooltip:
'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup do banco de dados',
name_your_wallet: 'Nomeie sua carteira %{name}',
name_your_wallet: 'Nomeie sua carteira {name}',
wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
'Sucesso ao criar fundos virtuais ({amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -58,10 +58,9 @@ window.localisation.br = {
donate: 'Doar',
view_github: 'Ver no GitHub',
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA',
service_fee: 'Taxa de serviço: %{amount} % por transação',
service_fee_max:
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
service_fee: 'Taxa de serviço: {amount} % por transação',
service_fee_max: 'Taxa de serviço: {amount} % por transação (máx {max} sats)',
service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro',
@@ -72,7 +71,7 @@ window.localisation.br = {
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta',
credit_label: '%{denomination} para creditar',
credit_label: '{denomination} para creditar',
paste: 'Colar',
paste_from_clipboard: 'Cole do clipboard',
paste_request: 'Colar Pedido',
@@ -155,8 +154,8 @@ window.localisation.br = {
expiry: 'Validade',
webhook: 'Webhook',
payment_proof: 'Comprovante de pagamento',
update_available: 'Atualização %{version} disponível!',
latest_update: 'Você está na versão mais recente %{version}.',
update_available: 'Atualização {version} disponível!',
latest_update: 'Você está na versão mais recente {version}.',
notifications: 'Notificações',
no_notifications: 'Sem notificações',
notifications_disabled:
@@ -247,8 +246,8 @@ window.localisation.br = {
look_and_feel: 'Aparência',
language: 'Idioma',
color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
release_details_error: 'Não é possível obter os detalhes da versão.',
pay_from_wallet: 'Pagar com a Carteira',
show_qr: 'Exibir QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.cn = {
reset_defaults: '重置为默认设置',
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字',
name_your_wallet: '给你的 {name}钱包起个名字',
wallet_topup_ok:
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
'成功创建虚拟资金({amount} sats)。付款取决于资金来源的实际资金。',
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description:
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
@@ -56,9 +56,9 @@ window.localisation.cn = {
donate: '捐献',
view_github: '在GitHub上查看',
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
service_fee: '服务费:%{amount}% 每笔交易',
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats',
use_with_caution: '请谨慎使用 - {name}钱包还处于测试版阶段',
service_fee: '服务费:{amount}% 每笔交易',
service_fee_max: '服务费:{amount}% 每笔交易(最高 {max} sats',
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
toggle_darkmode: '切换暗黑模式',
payment_reactions: '支付反应',
@@ -68,7 +68,7 @@ window.localisation.cn = {
lnbits_version: 'LNbits版本',
runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户',
credit_label: '%{denomination} 充值',
credit_label: '{denomination} 充值',
paste: '粘贴',
paste_from_clipboard: '从剪贴板粘贴',
paste_request: '粘贴请求',
@@ -146,8 +146,8 @@ window.localisation.cn = {
expiry: '过期时间',
webhook: 'Webhook',
payment_proof: '付款证明',
update_available: '更新%{version}可用!',
latest_update: '您当前使用的是最新版本%{version}。',
update_available: '更新{version}可用!',
latest_update: '您当前使用的是最新版本{version}。',
notifications: '通知',
no_notifications: '没有通知',
notifications_disabled: 'LNbits状态通知已禁用。',
@@ -235,8 +235,8 @@ window.localisation.cn = {
look_and_feel: '外观和感觉',
language: '语言',
color_scheme: '配色方案',
extension_cost: '此版本需要支付最低 %{cost} sats。',
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
extension_cost: '此版本需要支付最低 {cost} sats。',
extension_paid_sats: '您已经支付了{paid_sats} sats。',
release_details_error: '无法获取发布详情。',
pay_from_wallet: '从钱包支付',
show_qr: '显示QR码',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.cs = {
reset_defaults: 'Obnovit výchozí',
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
download_backup: 'Stáhnout zálohu databáze',
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
name_your_wallet: 'Pojmenujte svou {name} peněženku',
wallet_topup_ok:
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
'Úspěšně vytvořeny virtuální prostředky ({amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
@@ -58,10 +58,10 @@ window.localisation.cs = {
view_github: 'Zobrazit na GitHubu',
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
use_with_caution:
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ',
service_fee: 'Servisný poplatek: %{amount} % za transakci',
'POUŽÍVEJTE S OBEZŘETNOSTÍ - {name} peněženka je stále v BETĚ',
service_fee: 'Servisný poplatek: {amount} % za transakci',
service_fee_max:
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)',
'Servisný poplatek: {amount} % za transakci (max {max} satoshi)',
service_fee_tooltip:
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
toggle_darkmode: 'Přepnout tmavý režim',
@@ -72,7 +72,7 @@ window.localisation.cs = {
lnbits_version: 'Verze LNbits',
runs_on: 'Běží na',
credit_hint: 'Stiskněte Enter pro připsání na účet',
credit_label: '%{denomination} k připsání',
credit_label: '{denomination} k připsání',
paste: 'Vložit',
paste_from_clipboard: 'Vložit ze schránky',
paste_request: 'Vložit požadavek',
@@ -153,8 +153,8 @@ window.localisation.cs = {
expiry: 'Expirace',
webhook: 'Webhook',
payment_proof: 'Důkaz platby',
update_available: 'Dostupná aktualizace %{version}!',
latest_update: 'Máte nejnovější verzi %{version}.',
update_available: 'Dostupná aktualizace {version}!',
latest_update: 'Máte nejnovější verzi {version}.',
notifications: 'Notifikace',
no_notifications: 'Žádné notifikace',
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
@@ -244,8 +244,8 @@ window.localisation.cs = {
look_and_feel: 'Vzhled a chování',
language: 'Jazyk',
color_scheme: 'Barevné schéma',
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
extension_cost: 'Toto vydání vyžaduje minimální platbu {cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili {paid_sats} sats.',
release_details_error: 'Nelze získat podrobnosti o vydání.',
pay_from_wallet: 'Platit z peněženky',
show_qr: 'Zobrazit QR',
+10 -12
View File
@@ -35,9 +35,9 @@ window.localisation.de = {
reset_defaults_tooltip:
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
download_backup: 'Datenbank-Backup herunterladen',
name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen',
name_your_wallet: 'Vergib deiner {name} Wallet einen Namen',
wallet_topup_ok:
'Erfolg beim Erstellen von virtuellen Mitteln (%{amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
'Erfolg beim Erstellen von virtuellen Mitteln ({amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description:
@@ -59,11 +59,9 @@ window.localisation.de = {
donate: 'Spenden',
view_github: 'Auf GitHub anzeigen',
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
use_with_caution:
'BITTE MIT VORSICHT BENUTZEN - %{name} Wallet ist noch BETA',
service_fee: 'Dienstleistungsgebühr: %{amount} % pro Transaktion',
service_fee_max:
'Servicegebühr: %{amount} % pro Transaktion (max %{max} Sats)',
use_with_caution: 'BITTE MIT VORSICHT BENUTZEN - {name} Wallet ist noch BETA',
service_fee: 'Dienstleistungsgebühr: {amount} % pro Transaktion',
service_fee_max: 'Servicegebühr: {amount} % pro Transaktion (max {max} Sats)',
service_fee_tooltip:
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
toggle_darkmode: 'Auf Dark Mode umschalten',
@@ -74,7 +72,7 @@ window.localisation.de = {
lnbits_version: 'LNbits-Version',
runs_on: 'Läuft auf',
credit_hint: 'Klicke Enter, um das Konto zu belasten',
credit_label: '%{denomination} zu belasten',
credit_label: '{denomination} zu belasten',
paste: 'Einfügen',
paste_from_clipboard: 'Einfügen aus der Zwischenablage',
paste_request: 'Anfrage einfügen',
@@ -158,8 +156,8 @@ window.localisation.de = {
expiry: 'Ablauf',
webhook: 'Webhook',
payment_proof: 'Beleg',
update_available: 'Aktualisierung %{version} verfügbar!',
latest_update: 'Sie sind auf der neuesten Version %{version}.',
update_available: 'Aktualisierung {version} verfügbar!',
latest_update: 'Sie sind auf der neuesten Version {version}.',
notifications: 'Benachrichtigungen',
no_notifications: 'Keine Benachrichtigungen',
notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.',
@@ -252,8 +250,8 @@ window.localisation.de = {
language: 'Sprache',
color_scheme: 'Farbschema',
extension_cost:
'Diese Version erfordert eine Zahlung von mindestens %{cost} Sats.',
extension_paid_sats: 'Sie haben bereits %{paid_sats} Sats bezahlt.',
'Diese Version erfordert eine Zahlung von mindestens {cost} Sats.',
extension_paid_sats: 'Sie haben bereits {paid_sats} Sats bezahlt.',
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
show_qr: 'QR anzeigen',
+19 -13
View File
@@ -34,9 +34,9 @@ window.localisation.en = {
reset_defaults: 'Reset to defaults',
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
name_your_wallet: 'Name your %{name} wallet',
name_your_wallet: 'Name your {name} wallet',
wallet_topup_ok:
'Success creating virtual funds (%{amount} sats). Payments depend on actual funds on funding source.',
'Success creating virtual funds ({amount} sats). Payments depend on actual funds on funding source.',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
@@ -57,9 +57,9 @@ window.localisation.en = {
donate: 'Donate',
view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled',
use_with_caution: 'USE WITH CAUTION - %{name} wallet is still in BETA',
service_fee: 'Service fee: %{amount} % per transaction',
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode',
@@ -70,7 +70,7 @@ window.localisation.en = {
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account',
credit_label: '%{denomination} to credit',
credit_label: '{denomination} to credit',
paste: 'Paste',
paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request',
@@ -152,8 +152,8 @@ window.localisation.en = {
expiry: 'Expiry',
webhook: 'Webhook',
payment_proof: 'Payment Proof',
update_available: 'Update %{version} available!',
latest_update: 'You are on the latest version %{version}.',
update_available: 'Update {version} available!',
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
no_notifications: 'No notifications',
notifications_disabled: 'LNbits status notifications are disabled.',
@@ -194,7 +194,7 @@ window.localisation.en = {
rate_limiter: 'Rate Limiter',
wallet_limiter: 'Wallet Limiter',
wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 to disable)',
'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
wallet_limit_secs_between_trans:
'Min secs between transactions per wallet (0 to disable)',
@@ -214,6 +214,7 @@ window.localisation.en = {
login_to_account: 'Login to your account',
create_account: 'Create account',
account_settings: 'Account Settings',
signin_with_nostr: 'Continue with Nostr',
signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak',
@@ -222,11 +223,14 @@ window.localisation.en = {
password_config: 'Password Config',
password_repeat: 'Password repeat',
change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
set_password: 'Set Password',
invalid_password: 'Password must have at least 8 characters',
login: 'Login',
register: 'Register',
username: 'Username',
pubkey: 'Public Key',
user_id: 'User ID',
email: 'Email',
first_name: 'First Name',
@@ -245,8 +249,8 @@ window.localisation.en = {
gradient_background: 'Gradient Background',
language: 'Language',
color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *',
@@ -258,9 +262,11 @@ window.localisation.en = {
sell: 'Sell',
sell_require: 'Ask payment to enable extension',
sell_info:
'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',
recheck: 'Recheck',
contributors: 'Contributors',
license: 'License'
license: 'License',
reset_key: 'Reset Key',
reset_password: 'Reset Password'
}
+10 -10
View File
@@ -35,9 +35,9 @@ window.localisation.es = {
reset_defaults_tooltip:
'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
download_backup: 'Descargar copia de seguridad de la base de datos',
name_your_wallet: 'Nombre de su billetera %{name}',
name_your_wallet: 'Nombre de su billetera {name}',
wallet_topup_ok:
'Éxito creando fondos virtuales (%{amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
'Éxito creando fondos virtuales ({amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
paste_invoice_label: 'Pegue la factura aquí',
lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
@@ -58,10 +58,10 @@ window.localisation.es = {
donate: 'Donar',
view_github: 'Ver en GitHub',
voidwallet_active: '¡VoidWallet está activo! Pagos desactivados',
use_with_caution: 'USAR CON CUIDADO - %{name} Wallet aún está en BETA',
service_fee: 'Tarifa de servicio: %{amount} % por transacción',
use_with_caution: 'USAR CON CUIDADO - {name} Wallet aún está en BETA',
service_fee: 'Tarifa de servicio: {amount} % por transacción',
service_fee_max:
'Tarifa de servicio: %{amount} % por transacción (máx %{max} sats)',
'Tarifa de servicio: {amount} % por transacción (máx {max} sats)',
service_fee_tooltip:
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
toggle_darkmode: 'Cambiar modo oscuro',
@@ -72,7 +72,7 @@ window.localisation.es = {
lnbits_version: 'Versión de LNbits',
runs_on: 'Corre en',
credit_hint: 'Presione Enter para cargar la cuenta',
credit_label: 'Cargar %{denomination}',
credit_label: 'Cargar {denomination}',
paste: 'Pegar',
paste_from_clipboard: 'Pegar desde el portapapeles',
paste_request: 'Pegar solicitud',
@@ -155,8 +155,8 @@ window.localisation.es = {
expiry: 'Expiración',
webhook: 'Webhook',
payment_proof: 'Prueba de pago',
update_available: '¡Actualización %{version} disponible!',
latest_update: 'Usted está en la última versión %{version}.',
update_available: '¡Actualización {version} disponible!',
latest_update: 'Usted está en la última versión {version}.',
notifications: 'Notificaciones',
no_notifications: 'No hay notificaciones',
notifications_disabled:
@@ -249,8 +249,8 @@ window.localisation.es = {
look_and_feel: 'Apariencia',
language: 'Idioma',
color_scheme: 'Esquema de colores',
extension_cost: 'Esta versión requiere un pago mínimo de %{cost} sats.',
extension_paid_sats: 'Ya has pagado %{paid_sats} sats.',
extension_cost: 'Esta versión requiere un pago mínimo de {cost} sats.',
extension_paid_sats: 'Ya has pagado {paid_sats} sats.',
release_details_error: 'No se pueden obtener los detalles de la versión.',
pay_from_wallet: 'Pagar desde la billetera',
show_qr: 'Mostrar QR',
+10 -11
View File
@@ -35,9 +35,9 @@ window.localisation.fi = {
reset_defaults_tooltip:
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio',
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
name_your_wallet: 'Anna {name}-lompakollesi nimi',
wallet_topup_ok:
'Virtuaalisten varojen luominen onnistui (%{amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
'Virtuaalisten varojen luominen onnistui ({amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description:
@@ -61,10 +61,9 @@ window.localisation.fi = {
voidwallet_active:
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
use_with_caution:
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: %{name}',
service_fee: 'Palvelumaksu: %{amount} % tapahtumasta',
service_fee_max:
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: {name}',
service_fee: 'Palvelumaksu: {amount} % tapahtumasta',
service_fee_max: 'Palvelumaksu: {amount} % tapahtumasta (enintään {max} sat)',
service_fee_tooltip:
'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä',
@@ -75,7 +74,7 @@ window.localisation.fi = {
lnbits_version: 'LNbits versio',
runs_on: 'Mukana menossa',
credit_hint: 'Hyväksy painamalla Enter',
credit_label: 'Lisää tilille varoja %{denomination}',
credit_label: 'Lisää tilille varoja {denomination}',
paste: 'Liitä',
paste_from_clipboard: 'Liitä leikepöydältä',
paste_request: 'Liitä pyyntö',
@@ -155,9 +154,9 @@ window.localisation.fi = {
expiry: 'Vanheneminen',
webhook: 'Webhook',
payment_proof: 'Maksun varmenne',
update_available: 'Saatavilla on päivitys versioon %{version}!',
update_available: 'Saatavilla on päivitys versioon {version}!',
latest_update:
'Käytössä oleva versio %{version}, on viimeisin saatavilla oleva.',
'Käytössä oleva versio {version}, on viimeisin saatavilla oleva.',
notifications: 'Tiedotteet',
no_notifications: 'Ei tiedotteita',
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
@@ -247,8 +246,8 @@ window.localisation.fi = {
look_and_feel: 'Kieli ja värit',
language: 'Kieli',
color_scheme: 'Väriteema',
extension_cost: 'Tämä julkaisu edellyttää vähintään %{cost} satsin maksua.',
extension_paid_sats: 'Olet jo maksanut %{paid_sats} satsia.',
extension_cost: 'Tämä julkaisu edellyttää vähintään {cost} satsin maksua.',
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta',
show_qr: 'Näytä QR',
+10 -11
View File
@@ -37,9 +37,9 @@ window.localisation.fr = {
reset_defaults_tooltip:
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
download_backup: 'Télécharger la sauvegarde de la base de données',
name_your_wallet: 'Nommez votre portefeuille %{name}',
name_your_wallet: 'Nommez votre portefeuille {name}',
wallet_topup_ok:
'Succès de la création de fonds virtuels (%{amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
'Succès de la création de fonds virtuels ({amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description:
@@ -62,10 +62,10 @@ window.localisation.fr = {
view_github: 'Voir sur GitHub',
voidwallet_active: 'VoidWallet est actif! Paiements désactivés',
use_with_caution:
'UTILISER AVEC PRUDENCE - Le portefeuille %{name} est toujours en version BETA',
service_fee: 'Frais de service : %{amount} % par transaction',
'UTILISER AVEC PRUDENCE - Le portefeuille {name} est toujours en version BETA',
service_fee: 'Frais de service : {amount} % par transaction',
service_fee_max:
'Frais de service : %{amount} % par transaction (max %{max} sats)',
'Frais de service : {amount} % par transaction (max {max} sats)',
service_fee_tooltip:
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
toggle_darkmode: 'Basculer le mode sombre',
@@ -76,7 +76,7 @@ window.localisation.fr = {
lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur',
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
credit_label: '%{denomination} à créditer',
credit_label: '{denomination} à créditer',
paste: 'Coller',
paste_from_clipboard: 'Coller depuis le presse-papiers',
paste_request: 'Coller la requête',
@@ -159,8 +159,8 @@ window.localisation.fr = {
expiry: 'Expiration',
webhook: 'Webhook',
payment_proof: 'Preuve de paiement',
update_available: 'Mise à jour %{version} disponible !',
latest_update: 'Vous êtes sur la dernière version %{version}.',
update_available: 'Mise à jour {version} disponible !',
latest_update: 'Vous êtes sur la dernière version {version}.',
notifications: 'Notifications',
no_notifications: 'Aucune notification',
notifications_disabled:
@@ -253,9 +253,8 @@ window.localisation.fr = {
look_and_feel: 'Apparence',
language: 'Langue',
color_scheme: 'Schéma de couleurs',
extension_cost:
'Cette version nécessite un paiement minimum de %{cost} sats.',
extension_paid_sats: 'Vous avez déjà payé %{paid_sats} sats.',
extension_cost: 'Cette version nécessite un paiement minimum de {cost} sats.',
extension_paid_sats: 'Vous avez déjà payé {paid_sats} sats.',
release_details_error: "Impossible d'obtenir les détails de la version.",
pay_from_wallet: 'Payer depuis le portefeuille',
show_qr: 'Afficher le QR',
+10 -10
View File
@@ -35,9 +35,9 @@ window.localisation.it = {
reset_defaults_tooltip:
'Cancella tutte le impostazioni e ripristina i valori predefiniti',
download_backup: 'Scarica il backup del database',
name_your_wallet: 'Dai un nome al tuo portafoglio %{name}',
name_your_wallet: 'Dai un nome al tuo portafoglio {name}',
wallet_topup_ok:
'Operazione riuscita nella creazione di fondi virtuali (%{amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
'Operazione riuscita nella creazione di fondi virtuali ({amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description:
@@ -59,10 +59,10 @@ window.localisation.it = {
donate: 'Donazioni',
view_github: 'Visualizza su GitHub',
voidwallet_active: 'VoidWallet è attivo! Pagamenti disabilitati',
use_with_caution: 'USARE CON CAUTELA - %{name} portafoglio è ancora in BETA',
service_fee: 'Commissione di servizio: %{amount} % per transazione',
use_with_caution: 'USARE CON CAUTELA - {name} portafoglio è ancora in BETA',
service_fee: 'Commissione di servizio: {amount} % per transazione',
service_fee_max:
'Commissione di servizio: %{amount} % per transazione (max %{max} sats)',
'Commissione di servizio: {amount} % per transazione (max {max} sats)',
service_fee_tooltip:
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
toggle_darkmode: 'Attiva la modalità notturna',
@@ -73,7 +73,7 @@ window.localisation.it = {
lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su',
credit_hint: 'Premere Invio per accreditare i fondi',
credit_label: '%{denomination} da accreditare',
credit_label: '{denomination} da accreditare',
paste: 'Incolla',
paste_from_clipboard: 'Incolla dagli appunti',
paste_request: 'Richiesta di pagamento',
@@ -156,8 +156,8 @@ window.localisation.it = {
expiry: 'Scadenza',
webhook: 'Webhook',
payment_proof: 'Prova di pagamento',
update_available: 'Aggiornamento %{version} disponibile!',
latest_update: 'Sei sulla versione più recente %{version}.',
update_available: 'Aggiornamento {version} disponibile!',
latest_update: 'Sei sulla versione più recente {version}.',
notifications: 'Notifiche',
no_notifications: 'Nessuna notifica',
notifications_disabled: 'Le notifiche di stato di LNbits sono disattivate.',
@@ -251,8 +251,8 @@ window.localisation.it = {
language: 'Lingua',
color_scheme: 'Schema dei colori',
extension_cost:
'Questa versione richiede un pagamento minimo di %{cost} satoshi.',
extension_paid_sats: 'Hai già pagato %{paid_sats} sats.',
'Questa versione richiede un pagamento minimo di {cost} satoshi.',
extension_paid_sats: 'Hai già pagato {paid_sats} sats.',
release_details_error: 'Impossibile ottenere i dettagli della versione.',
pay_from_wallet: 'Paga dal Portafoglio',
show_qr: 'Mostra QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.jp = {
reset_defaults: 'リセット',
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
download_backup: 'データベースのバックアップをダウンロードする',
name_your_wallet: 'あなたのウォレットの名前 %{name}',
name_your_wallet: 'あなたのウォレットの名前 {name}',
wallet_topup_ok:
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。',
'仮想資金の作成に成功しました({amount} sats)。支払いは資金ソースの実際の資金に依存します。',
paste_invoice_label: '請求書を貼り付けてください',
lnbits_description:
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
@@ -58,9 +58,9 @@ window.localisation.jp = {
view_github: 'GitHubで表示',
voidwallet_active: 'Voidwalletアクティブ',
use_with_caution:
'注意して使用してください - %{name} ウォレットはまだベータ版です',
service_fee: '取引ごとのサービス手数料: %{amount} %',
service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)',
'注意して使用してください - {name} ウォレットはまだベータ版です',
service_fee: '取引ごとのサービス手数料: {amount} %',
service_fee_max: '取引手数料:{amount}%(最大{max}サトシ)',
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
toggle_darkmode: 'ダークモードを切り替える',
payment_reactions: '支払いの反応',
@@ -71,7 +71,7 @@ window.localisation.jp = {
runs_on: 'で実行',
credit_hint:
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
credit_label: '%{denomination} をクレジットに',
credit_label: '{denomination} をクレジットに',
paste: '貼り付け',
paste_from_clipboard: 'クリップボードから貼り付け',
paste_request: 'リクエストを貼り付ける',
@@ -153,8 +153,8 @@ window.localisation.jp = {
expiry: '有効期限',
webhook: 'ウェブフック',
payment_proof: '支払い証明',
update_available: 'アップデート%{version}が利用可能です!',
latest_update: 'あなたは最新バージョン%{version}を使用しています。',
update_available: 'アップデート{version}が利用可能です!',
latest_update: 'あなたは最新バージョン{version}を使用しています。',
notifications: '通知',
no_notifications: '通知はありません',
notifications_disabled: 'LNbitsステータス通知は無効です。',
@@ -246,8 +246,8 @@ window.localisation.jp = {
look_and_feel: 'ルック・アンド・フィール',
language: '言語',
color_scheme: 'カラースキーム',
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。',
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。',
extension_cost: 'このリリースには最低 {cost} サトシの支払いが必要です。',
extension_paid_sats: 'すでに{paid_sats} satsを支払いました。',
release_details_error: 'リリースの詳細を取得できません。',
pay_from_wallet: 'ウォレットから支払う',
show_qr: 'QRを表示',
+10 -10
View File
@@ -35,9 +35,9 @@ window.localisation.kr = {
reset_defaults_tooltip:
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
download_backup: '데이터베이스 백업 다운로드',
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
name_your_wallet: '사용할 {name}지갑의 이름을 정하세요',
wallet_topup_ok:
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
'성공적으로 가상 자금을 생성했습니다 ({amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description:
'설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
@@ -58,9 +58,9 @@ window.localisation.kr = {
donate: '기부',
view_github: 'GitHub 페이지 보기',
voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.',
use_with_caution: '주의하세요 - %{name} 지갑은 아직 BETA 단계입니다.',
service_fee: '서비스 수수료: 거래액의 %{amount} %',
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
use_with_caution: '주의하세요 - {name} 지갑은 아직 BETA 단계입니다.',
service_fee: '서비스 수수료: 거래액의 {amount} %',
service_fee_max: '서비스 수수료: 거래액의 {amount} % (최대 {max} sats)',
service_fee_tooltip:
'지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환',
@@ -71,7 +71,7 @@ window.localisation.kr = {
lnbits_version: 'LNbits 버전',
runs_on: 'Runs on',
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
credit_label: '%{denomination} 단위로 충전하기',
credit_label: '{denomination} 단위로 충전하기',
paste: '붙여넣기',
paste_from_clipboard: '클립보드에서 붙여넣기',
paste_request: '지불 요청 붙여넣기',
@@ -153,8 +153,8 @@ window.localisation.kr = {
expiry: '만료',
webhook: 'Webhook',
payment_proof: 'Payment 증거',
update_available: '%{version}으로 업데이트가 가능합니다.',
latest_update: '이미 %{version} 버전으로 업데이트되었습니다.',
update_available: '{version}으로 업데이트가 가능합니다.',
latest_update: '이미 {version} 버전으로 업데이트되었습니다.',
notifications: '알림',
no_notifications: '알림 없음',
notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.',
@@ -243,8 +243,8 @@ window.localisation.kr = {
look_and_feel: '외관과 느낌',
language: '언어',
color_scheme: '색상 구성',
extension_cost: '이 버전은 최소 %{cost} sats의 지불이 필요합니다.',
extension_paid_sats: '당신은 이미 %{paid_sats} sats를 지불했습니다.',
extension_cost: '이 버전은 최소 {cost} sats의 지불이 필요합니다.',
extension_paid_sats: '당신은 이미 {paid_sats} sats를 지불했습니다.',
release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.',
pay_from_wallet: '지갑에서 결제하다',
show_qr: 'QR 보기',
+10 -12
View File
@@ -36,9 +36,9 @@ window.localisation.nl = {
reset_defaults_tooltip:
'Wis alle instellingen en herstel de standaardinstellingen.',
download_backup: 'Databaseback-up downloaden',
name_your_wallet: 'Geef je %{name} portemonnee een naam',
name_your_wallet: 'Geef je {name} portemonnee een naam',
wallet_topup_ok:
'Succes met het aanmaken van virtuele fondsen (%{amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.',
'Succes met het aanmaken van virtuele fondsen ({amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.',
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
lnbits_description:
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
@@ -60,10 +60,9 @@ window.localisation.nl = {
view_github: 'Bekijken op GitHub',
voidwallet_active: 'VoidWallet is actief! Betalingen uitgeschakeld',
use_with_caution:
'GEBRUIK MET VOORZICHTIGHEID - %{name} portemonnee is nog in BETA',
service_fee: 'Servicekosten: %{amount} % per transactie',
service_fee_max:
'Servicekosten: %{amount} % per transactie (max %{max} sats)',
'GEBRUIK MET VOORZICHTIGHEID - {name} portemonnee is nog in BETA',
service_fee: 'Servicekosten: {amount} % per transactie',
service_fee_max: 'Servicekosten: {amount} % per transactie (max {max} sats)',
service_fee_tooltip:
'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie',
toggle_darkmode: 'Donkere modus aan/uit',
@@ -74,7 +73,7 @@ window.localisation.nl = {
lnbits_version: 'LNbits-versie',
runs_on: 'Draait op',
credit_hint: 'Druk op Enter om de rekening te crediteren',
credit_label: '%{denomination} te crediteren',
credit_label: '{denomination} te crediteren',
paste: 'Plakken',
paste_from_clipboard: 'Plakken van klembord',
paste_request: 'Verzoek plakken',
@@ -156,8 +155,8 @@ window.localisation.nl = {
expiry: 'Vervaldatum',
webhook: 'Webhook',
payment_proof: 'Betalingsbewijs',
update_available: 'Update %{version} beschikbaar!',
latest_update: 'U bent op de nieuwste versie %{version}.',
update_available: 'Update {version} beschikbaar!',
latest_update: 'U bent op de nieuwste versie {version}.',
notifications: 'Meldingen',
no_notifications: 'Geen meldingen',
notifications_disabled: 'LNbits-statusmeldingen zijn uitgeschakeld.',
@@ -249,9 +248,8 @@ window.localisation.nl = {
look_and_feel: 'Uiterlijk en gedrag',
language: 'Taal',
color_scheme: 'Kleurenschema',
extension_cost:
'Deze release vereist een betaling van minimaal %{cost} sats.',
extension_paid_sats: 'U heeft al %{paid_sats} sats betaald.',
extension_cost: 'Deze release vereist een betaling van minimaal {cost} sats.',
extension_paid_sats: 'U heeft al {paid_sats} sats betaald.',
release_details_error: 'Kan de gegevens van de release niet ophalen.',
pay_from_wallet: 'Betalen vanuit Portemonnee',
show_qr: 'Toon QR',
+12 -12
View File
@@ -35,9 +35,9 @@ window.localisation.pi = {
reset_defaults_tooltip:
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
download_backup: 'Download database booty',
name_your_wallet: 'Name yer %{name} treasure chest',
name_your_wallet: 'Name yer {name} treasure chest',
wallet_topup_ok:
"Success creatin' virtual funds (%{amount} sats). Payments depend on actual funds on funding source.",
"Success creatin' virtual funds ({amount} sats). Payments depend on actual funds on funding source.",
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
lnbits_description:
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
@@ -59,9 +59,9 @@ window.localisation.pi = {
view_github: 'View on GitHub and find treasures',
voidwallet_active: 'VoidWallet be active! Payments disabled',
use_with_caution:
'USE WITH CAUTION - %{name} chest be still in BETA. Aye, be careful!',
service_fee: 'Service fee: %{amount} % per transaction',
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
'USE WITH CAUTION - {name} chest be still in BETA. Aye, be careful!',
service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_tooltip:
"Service fee charged by the LNbits server admin per goin' transaction",
toggle_darkmode: 'Toggle Dark Mode, arr!',
@@ -72,7 +72,7 @@ window.localisation.pi = {
lnbits_version: 'LNbits version, arr!',
runs_on: 'Runs on, matey',
credit_hint: 'Press Enter to credit account and make it richer',
credit_label: '%{denomination} to credit, arr!',
credit_label: '{denomination} to credit, arr!',
paste: 'Stow',
paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request and find treasures',
@@ -155,8 +155,8 @@ window.localisation.pi = {
expiry: 'Expiry like the food on a ship, ye landlubber',
webhook: 'Webhook like a fishing line, arrr',
payment_proof: 'Payment Proof like a seal of authenticity, argh',
update_available: 'Update %{version} available, me matey!',
latest_update: "Ye be on th' latest version %{version}.",
update_available: 'Update {version} available, me matey!',
latest_update: "Ye be on th' latest version {version}.",
notifications: 'Notificashuns',
no_notifications: "No noticin's",
notifications_disabled: 'LNbits status notifications be disabled, arr!',
@@ -197,7 +197,7 @@ window.localisation.pi = {
rate_limiter: 'Rate Limiter',
wallet_limiter: 'Pouch Limitar',
wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 ter disable)',
'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)',
wallet_limit_secs_between_trans:
"Min secs 'tween transactions per wallet (0 to disable)",
@@ -206,7 +206,7 @@ window.localisation.pi = {
minute: 'minnit',
second: 'second',
hour: 'hour',
disable_server_log: "Disabl' %{Server} Log",
disable_server_log: "Disabl' {Server} Log",
enable_server_log: 'Enable Server Log',
coming_soon: "Feature comin' soon",
session_has_expired: 'Yer session has expired. Please login again.',
@@ -247,8 +247,8 @@ window.localisation.pi = {
language: 'Langwidge',
color_scheme: 'Colour Scheme',
extension_cost:
"This release be needin' a payment o' minimum %{cost} sats, arr.",
extension_paid_sats: 'Ye have already paid %{paid_sats} sats.',
"This release be needin' a payment o' minimum {cost} sats, arr.",
extension_paid_sats: 'Ye have already paid {paid_sats} sats.',
release_details_error: "Cannot get th' release details.",
pay_from_wallet: 'Pay from ye Wallet',
show_qr: 'Show QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.pl = {
reset_defaults: 'Powrót do ustawień domyślnych',
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
download_backup: 'Pobierz kopię zapasową bazy danych',
name_your_wallet: 'Nazwij swój portfel %{name}',
name_your_wallet: 'Nazwij swój portfel {name}',
wallet_topup_ok:
'Sukces w tworzeniu wirtualnych środków (%{amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.',
'Sukces w tworzeniu wirtualnych środków ({amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.',
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
lnbits_description:
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
@@ -57,10 +57,10 @@ window.localisation.pl = {
donate: 'Podaruj',
view_github: 'Otwórz GitHub',
voidwallet_active: 'VoidWallet jest aktywny! Płatności są niemożliwe',
use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel %{name} jest w wersji BETA',
service_fee: 'Opłata serwisowa: %{amount} % za transakcję',
use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel {name} jest w wersji BETA',
service_fee: 'Opłata serwisowa: {amount} % za transakcję',
service_fee_max:
'Opłata serwisowa: %{amount} % za transakcję (maks %{max} sat)',
'Opłata serwisowa: {amount} % za transakcję (maks {max} sat)',
service_fee_tooltip:
'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję',
toggle_darkmode: 'Tryb nocny',
@@ -71,7 +71,7 @@ window.localisation.pl = {
lnbits_version: 'Wersja LNbits',
runs_on: 'Działa na',
credit_hint: 'Naciśnij Enter aby doładować konto',
credit_label: '%{denomination} doładowanie',
credit_label: '{denomination} doładowanie',
paste: 'Wklej',
paste_from_clipboard: 'Wklej ze schowka',
paste_request: 'Wklej żądanie',
@@ -153,8 +153,8 @@ window.localisation.pl = {
expiry: 'Wygasa',
webhook: 'Webhook',
payment_proof: 'Potwierdzenie płatności',
update_available: 'Aktualizacja %{version} dostępna!',
latest_update: 'Korzystasz z najnowszej wersji %{version}.',
update_available: 'Aktualizacja {version} dostępna!',
latest_update: 'Korzystasz z najnowszej wersji {version}.',
notifications: 'Powiadomienia',
no_notifications: 'Brak powiadomień',
notifications_disabled: 'Powiadomienia o statusie LNbits są wyłączone.',
@@ -246,8 +246,8 @@ window.localisation.pl = {
language: 'Język',
color_scheme: 'Schemat kolorów',
extension_cost:
'To niniejsze wydanie wymaga zapłaty minimalnej %{cost} satów.',
extension_paid_sats: 'Już zapłaciłeś %{paid_sats} satów.',
'To niniejsze wydanie wymaga zapłaty minimalnej {cost} satów.',
extension_paid_sats: 'Już zapłaciłeś {paid_sats} satów.',
release_details_error: 'Nie można uzyskać szczegółów wydania.',
pay_from_wallet: 'Zapłać z portfela',
show_qr: 'Pokaż kod QR',
+10 -10
View File
@@ -35,9 +35,9 @@ window.localisation.pt = {
reset_defaults_tooltip:
'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup da base de dados',
name_your_wallet: 'Nomeie sua carteira %{name}',
name_your_wallet: 'Nomeie sua carteira {name}',
wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.',
'Sucesso ao criar fundos virtuais ({amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -58,10 +58,10 @@ window.localisation.pt = {
donate: 'Doar',
view_github: 'Ver no GitHub',
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA',
service_fee: 'Taxa de serviço: %{amount} % por transação',
use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
service_fee: 'Taxa de serviço: {amount} % por transação',
service_fee_max:
'Taxa de serviço: %{amount} % por transação (máximo de %{max} sats)',
'Taxa de serviço: {amount} % por transação (máximo de {max} sats)',
service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro',
@@ -72,7 +72,7 @@ window.localisation.pt = {
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta',
credit_label: '%{denomination} para creditar',
credit_label: '{denomination} para creditar',
paste: 'Colar',
paste_from_clipboard: 'Colar da área de transferência',
paste_request: 'Colar Pedido',
@@ -154,8 +154,8 @@ window.localisation.pt = {
expiry: 'Validade',
webhook: 'Webhook',
payment_proof: 'Comprovativo de pagamento',
update_available: 'Atualização %{version} disponível!',
latest_update: 'Você está na última versão %{version}.',
update_available: 'Atualização {version} disponível!',
latest_update: 'Você está na última versão {version}.',
notifications: 'Notificações',
no_notifications: 'Sem notificações',
notifications_disabled:
@@ -246,8 +246,8 @@ window.localisation.pt = {
look_and_feel: 'Aparência e Sensação',
language: 'Idioma',
color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
release_details_error: 'Não é possível obter os detalhes da versão.',
pay_from_wallet: 'Pague da Carteira',
show_qr: 'Exibir QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.sk = {
reset_defaults: 'Obnoviť predvolené',
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
download_backup: 'Stiahnuť zálohu databázy',
name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
name_your_wallet: 'Pomenujte vašu {name} peňaženku',
wallet_topup_ok:
'Úspešne vytvorené virtuálne prostriedky (%{amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.',
'Úspešne vytvorené virtuálne prostriedky ({amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.',
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
lnbits_description:
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
@@ -57,10 +57,10 @@ window.localisation.sk = {
donate: 'Prispieť',
view_github: 'Zobraziť na GitHube',
voidwallet_active: 'VoidWallet je aktívny! Platby zakázané',
use_with_caution: 'POUŽÍVAJTE OPATRNE - %{name} peňaženka je stále v BETE',
service_fee: 'Servisný poplatok: %{amount} % za transakciu',
use_with_caution: 'POUŽÍVAJTE OPATRNE - {name} peňaženka je stále v BETE',
service_fee: 'Servisný poplatok: {amount} % za transakciu',
service_fee_max:
'Servisný poplatok: %{amount} % za transakciu (max %{max} satoshi)',
'Servisný poplatok: {amount} % za transakciu (max {max} satoshi)',
service_fee_tooltip:
'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu',
toggle_darkmode: 'Prepnúť Tmavý režim',
@@ -71,7 +71,7 @@ window.localisation.sk = {
lnbits_version: 'Verzia LNbits',
runs_on: 'Beží na',
credit_hint: 'Stlačte Enter pre pripísanie na účet',
credit_label: '%{denomination} na pripísanie',
credit_label: '{denomination} na pripísanie',
paste: 'Vložiť',
paste_from_clipboard: 'Vložiť zo schránky',
paste_request: 'Vložiť požiadavku',
@@ -153,8 +153,8 @@ window.localisation.sk = {
expiry: 'Expirácia',
webhook: 'Webhook',
payment_proof: 'Dôkaz platby',
update_available: 'Dostupná aktualizácia %{version}!',
latest_update: 'Máte najnovšiu verziu %{version}.',
update_available: 'Dostupná aktualizácia {version}!',
latest_update: 'Máte najnovšiu verziu {version}.',
notifications: 'Notifikácie',
no_notifications: 'Žiadne notifikácie',
notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.',
@@ -245,8 +245,8 @@ window.localisation.sk = {
look_and_feel: 'Vzhľad a dojem',
language: 'Jazyk',
color_scheme: 'Farebná schéma',
extension_cost: 'Táto verzia vyžaduje minimálnu platbu %{cost} satoshi.',
extension_paid_sats: 'Už ste zaplatili %{paid_sats} sats.',
extension_cost: 'Táto verzia vyžaduje minimálnu platbu {cost} satoshi.',
extension_paid_sats: 'Už ste zaplatili {paid_sats} sats.',
release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.',
pay_from_wallet: 'Zaplatiť z peňaženky',
show_qr: 'Zobraziť QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.we = {
reset_defaults: 'Ailosod i`r rhagosodiadau',
reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.',
download_backup: 'Lawrlwytho copi wrth gefn cronfa ddata',
name_your_wallet: 'Enwch eich waled %{name}',
name_your_wallet: 'Enwch eich waled {name}',
wallet_topup_ok:
"Llwyddiant wrth greu cronfeydd rhithwir (%{amount} sats). Mae taliadau'n dibynnu ar gronfeydd gwirioneddol ar y ffynhonnell cyllido.",
"Llwyddiant wrth greu cronfeydd rhithwir ({amount} sats). Mae taliadau'n dibynnu ar gronfeydd gwirioneddol ar y ffynhonnell cyllido.",
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
lnbits_description:
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
@@ -58,10 +58,10 @@ window.localisation.we = {
view_github: 'Gweld ar GitHub',
voidwallet_active:
' Mae VoidWallet yn weithredol! Taliadau wedi`u hanalluogi',
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled %{name} yn dal yn BETA',
service_fee: 'Ffi gwasanaeth: %{amount} % y trafodiad',
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled {name} yn dal yn BETA',
service_fee: 'Ffi gwasanaeth: {amount} % y trafodiad',
service_fee_max:
'Ffi gwasanaeth: %{amount} % y trafodiad (uchafswm %{max} sats)',
'Ffi gwasanaeth: {amount} % y trafodiad (uchafswm {max} sats)',
service_fee_tooltip:
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
toggle_darkmode: 'Toglo Modd Tywyll',
@@ -72,7 +72,7 @@ window.localisation.we = {
lnbits_version: 'Fersiwn LNbits',
runs_on: 'Yn rhedeg ymlaen',
credit_hint: 'Pwyswch Enter i gyfrif credyd',
credit_label: '%{denomination} i gredyd',
credit_label: '{denomination} i gredyd',
paste: 'Gludo',
paste_from_clipboard: "Gludo o'r clipfwrdd",
paste_request: 'Gludo Cais',
@@ -153,8 +153,8 @@ window.localisation.we = {
expiry: 'dod i ben',
webhook: 'bachyn we',
payment_proof: 'prawf taliad',
update_available: 'Diweddariad %{version} ar gael!',
latest_update: 'Rydych chi ar y fersiwn diweddaraf %{version}.',
update_available: 'Diweddariad {version} ar gael!',
latest_update: 'Rydych chi ar y fersiwn diweddaraf {version}.',
notifications: 'Hysbysiadau',
no_notifications: 'Dim hysbysiadau',
notifications_disabled: "Hysbysiadau statws LNbits wedi'u analluogi.",
@@ -245,8 +245,8 @@ window.localisation.we = {
look_and_feel: 'Edrych a Theimlo',
language: 'Iaith',
color_scheme: 'Cynllun Lliw',
extension_cost: "Mae'r rhyddhad hwn yn gofyn am daliad o leiaf %{cost} sats.",
extension_paid_sats: 'Rydych chi eisoes wedi talu %{paid_sats} sats.',
extension_cost: "Mae'r rhyddhad hwn yn gofyn am daliad o leiaf {cost} sats.",
extension_paid_sats: 'Rydych chi eisoes wedi talu {paid_sats} sats.',
release_details_error: 'Methu cael manylion y rhyddhau.',
pay_from_wallet: "Talu o'r Waled",
show_qr: 'Dangos QR',
+6
View File
@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04502 6.46189C3.1858 7.47829 2.51519 8.66234 2.10654 9.96165C4.43272 9.43773 7.52382 9.31199 9.27369 9.17577C9.81856 6.93342 11.3694 5.22546 13.968 5.34072C15.0891 5.39311 16.0741 6.11611 16.7552 7.22681C17.2582 6.69242 17.9288 6.33615 18.8194 6.21042C18.8823 6.21042 19.0185 6.19994 19.1233 6.19994C17.2791 4.1881 14.6491 2.90975 11.7047 2.90975C11.1703 2.90975 10.6464 2.96214 10.1224 3.04597C10.0177 3.04597 9.88143 3.06693 9.7033 3.10884C9.69282 3.10884 9.67187 3.10884 9.66139 3.10884C9.65091 3.10884 9.64043 3.10884 9.61948 3.10884C7.58669 3.60132 6.05685 3.20314 5.18716 2.0191C5.09285 1.89336 4.72611 1.33801 4.61085 0.363525C3.97167 1.02366 3.55254 2.1134 3.9088 3.27649C4.19172 4.19858 4.72611 4.69106 5.30242 4.97398C4.42224 5.02637 3.68876 4.85871 2.95528 4.30337C2.52567 3.97854 2.25323 3.62228 1.80267 2.68971C1.38353 3.34984 1.42545 4.08332 1.50927 4.46054C1.61406 4.9635 1.87601 5.52933 2.21132 5.85415C2.72476 6.35711 3.45824 6.47237 4.0555 6.46189H4.04502Z" fill="white"/>
<path d="M13.9782 15.1276C15.2803 15.1276 16.3358 13.3215 16.3358 11.0935C16.3358 8.86547 15.2803 7.05933 13.9782 7.05933C12.6761 7.05933 11.6206 8.86547 11.6206 11.0935C11.6206 13.3215 12.6761 15.1276 13.9782 15.1276Z" fill="white"/>
<path d="M19.165 14.1532C20.1835 14.1532 21.0092 12.7177 21.0092 10.9468C21.0092 9.17601 20.1835 7.74048 19.165 7.74048C18.1465 7.74048 17.3208 9.17601 17.3208 10.9468C17.3208 12.7177 18.1465 14.1532 19.165 14.1532Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0339 31.9777C10.0445 31.5085 10.2194 30.0309 11.7152 27.0729C12.3753 25.7841 13.8737 24.0028 14.9949 23.0387C15.5922 22.5148 16.1894 22.0747 16.7762 21.6661C17.0801 21.488 17.363 21.2993 17.6354 21.1003C22.5434 18.0911 26.876 18.7195 30.4704 19.2408L30.5761 19.2561C30.5761 19.2561 31.4354 16.6994 27.8937 15.3791C25.9657 14.6666 23.6919 14.0903 21.7953 13.6921C21.5229 14.1846 21.1771 14.6037 20.7684 14.9181C20.7628 14.9223 20.7571 14.9266 20.7512 14.931C20.4473 15.1594 19.7267 15.7009 18.4213 15.5468C17.7507 15.4629 17.2373 15.1905 16.8286 14.7923C16.137 15.9345 15.1416 16.6784 13.989 16.7832C10.7931 17.0556 9.17945 14.3732 9.07466 11.4078C6.67513 11.6384 3.4059 13.1158 1.6665 13.975L1.6755 29.386C3.89486 30.2461 7.22426 31.3076 10.0339 31.9777Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+51 -22
View File
@@ -13,20 +13,22 @@ window.app = Vue.createApp({
'confettiStars'
],
tab: 'user',
passwordData: {
credentialsData: {
show: false,
oldPassword: null,
newPassword: null,
newPasswordRepeat: null
newPasswordRepeat: null,
username: null,
pubkey: null
}
}
},
methods: {
activeLanguage: function (lang) {
return window.i18n.locale === lang
return window.i18n.global.locale === lang
},
changeLanguage: function (newValue) {
window.i18n.locale = newValue
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
toggleDarkMode: function () {
@@ -90,11 +92,12 @@ window.app = Vue.createApp({
user_id: this.user.id,
username: this.user.username,
email: this.user.email,
config: this.user.config
extra: this.user.extra
}
)
this.user = data
this.$q.notify({
this.hasUsername = !!data.username
Quasar.Notify.create({
type: 'positive',
message: 'Account updated.'
})
@@ -102,11 +105,19 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
disableUpdatePassword: function () {
return (
!this.credentialsData.newPassword ||
!this.credentialsData.newPasswordRepeat ||
this.credentialsData.newPassword !==
this.credentialsData.newPasswordRepeat
)
},
updatePassword: async function () {
if (!this.user.username) {
this.$q.notify({
if (!this.credentialsData.username) {
Quasar.Notify.create({
type: 'warning',
message: 'Please set a username first.'
message: 'Please set a username.'
})
return
}
@@ -117,15 +128,16 @@ window.app = Vue.createApp({
null,
{
user_id: this.user.id,
username: this.user.username,
password_old: this.passwordData.oldPassword,
password: this.passwordData.newPassword,
password_repeat: this.passwordData.newPasswordRepeat
username: this.credentialsData.username,
password_old: this.credentialsData.oldPassword,
password: this.credentialsData.newPassword,
password_repeat: this.credentialsData.newPasswordRepeat
}
)
this.user = data
this.passwordData.show = false
this.$q.notify({
this.hasUsername = !!data.username
this.credentialsData.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Password updated.'
})
@@ -133,17 +145,34 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
showChangePassword: function () {
if (!this.user.username) {
updatePubkey: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/pubkey',
null,
{
user_id: this.user.id,
pubkey: this.credentialsData.pubkey
}
)
this.user = data
this.hasUsername = !!data.username
this.credentialsData.show = false
this.$q.notify({
type: 'warning',
message: 'Please set a username first.'
type: 'positive',
message: 'Public key updated.'
})
return
} catch (e) {
LNbits.utils.notifyApiError(e)
}
this.passwordData = {
},
showUpdateCredentials: function () {
this.credentialsData = {
show: true,
oldPassword: null,
username: this.user.username,
pubkey: this.user.pubkey,
newPassword: null,
newPasswordRepeat: null
}
@@ -154,7 +183,7 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data
this.hasUsername = !!data.username
if (!this.user.config) this.user.config = {}
if (!this.user.extra) this.user.extra = {}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
+14 -3
View File
@@ -41,6 +41,7 @@ window.app = Vue.createApp({
formAddExtensionsManifest: '',
formAllowedIPs: '',
formBlockedIPs: '',
nostrAcceptedUrl: '',
isSuperUser: false,
wallet: {},
cancel: {},
@@ -181,11 +182,21 @@ window.app = Vue.createApp({
b => b !== blocked_ip
)
},
addNostrUrl() {
const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url)
this.formData.nostr_absolute_request_urls.push(url)
this.nostrAcceptedUrl = ''
},
removeNostrUrl(url) {
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
restartServer() {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! Restarted Server',
icon: null
@@ -260,7 +271,7 @@ window.app = Vue.createApp({
this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch
this.settings = this.formData
this.formData = _.clone(this.settings)
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: `Success! Settings changed! ${
this.needsRestart ? 'Restart required!' : ''
@@ -279,7 +290,7 @@ window.app = Vue.createApp({
LNbits.api
.request('DELETE', '/admin/api/v1/settings')
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message:
'Success! Restored settings to defaults, restart required!',
+52 -18
View File
@@ -17,6 +17,9 @@ window.LNbits = {
data: data
})
},
getServerHealth: function () {
return this.request('get', '/api/v1/health')
},
createInvoice: async function (
wallet,
amount,
@@ -78,6 +81,17 @@ window.LNbits = {
}
})
},
reset: function (reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
})
},
login: function (username, password) {
return axios({
method: 'POST',
@@ -85,6 +99,14 @@ window.LNbits = {
data: {username, password}
})
},
loginByProvider: function (provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
},
loginUsr: function (usr) {
return axios({
method: 'POST',
@@ -140,10 +162,15 @@ window.LNbits = {
)
},
updateBalance: function (credit, wallet_id) {
return LNbits.api.request('PUT', '/users/api/v1/topup', null, {
return this.request('PUT', '/users/api/v1/topup', null, {
amount: credit,
id: wallet_id
})
},
getCurrencies() {
return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data]
})
}
},
events: {
@@ -182,7 +209,7 @@ window.LNbits = {
},
map: {
extension: function (data) {
var obj = _.object(
const obj = _.object(
[
'code',
'isValid',
@@ -199,7 +226,7 @@ window.LNbits = {
return obj
},
user: function (data) {
var obj = {
const obj = {
id: data.id,
admin: data.admin,
email: data.email,
@@ -207,7 +234,7 @@ window.LNbits = {
wallets: data.wallets,
admin: data.admin
}
var mapWallet = this.wallet
const mapWallet = this.wallet
obj.wallets = obj.wallets
.map(function (obj) {
return mapWallet(obj)
@@ -251,7 +278,7 @@ window.LNbits = {
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra,
extra: data.extra ?? {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
@@ -310,6 +337,12 @@ window.LNbits = {
.join('')
return hashHex
},
formatDate: function (timestamp) {
return Quasar.date.formatDate(
new Date(timestamp * 1000),
'YYYY-MM-DD HH:mm'
)
},
formatCurrency: function (value, currency) {
return new Intl.NumberFormat(window.LOCALE, {
style: 'currency',
@@ -323,13 +356,15 @@ window.LNbits = {
return this.formatSat(value / 1000)
},
notifyApiError: function (error) {
console.error(error)
var types = {
if (!error.response) {
return console.error(error)
}
const types = {
400: 'warning',
401: 'warning',
500: 'negative'
}
Quasar.plugins.Notify.create({
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message:
@@ -343,9 +378,9 @@ window.LNbits = {
},
search: function (data, q, field, separator) {
try {
var queries = q.toLowerCase().split(separator || ' ')
const queries = q.toLowerCase().split(separator || ' ')
return data.filter(function (obj) {
var matches = 0
let matches = 0
_.each(queries, function (q) {
if (obj[field].indexOf(q) !== -1) matches++
})
@@ -374,8 +409,8 @@ window.LNbits = {
return new URLSearchParams(query)
},
exportCSV: function (columns, data, fileName) {
var wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val
const wrapCsvValue = function (val, formatFn) {
let formatted = formatFn !== void 0 ? formatFn(val) : val
formatted =
formatted === void 0 || formatted === null ? '' : String(formatted)
@@ -385,7 +420,7 @@ window.LNbits = {
return `"${formatted}"`
}
var content = [
const content = [
columns.map(function (col) {
return wrapCsvValue(col.label)
})
@@ -406,7 +441,7 @@ window.LNbits = {
)
.join('\r\n')
var status = Quasar.exportFile(
const status = Quasar.exportFile(
`${fileName || 'table-export'}.csv`,
content,
'text/csv'
@@ -503,9 +538,8 @@ window.windowMixin = {
)
},
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.copyToClipboard(text).then(function () {
notify({
Quasar.Notify.create({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
@@ -616,7 +650,7 @@ window.windowMixin = {
let locale = this.$q.localStorage.getItem('lnbits.lang')
if (locale) {
window.LOCALE = locale
window.i18n.locale = locale
window.i18n.global.locale = locale
}
this.g.langs = window.langs ?? []
@@ -658,7 +692,7 @@ window.windowMixin = {
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
}
if (window.extensions) {
var user = this.g.user
const user = this.g.user
const extensions = Object.freeze(
window.extensions
.map(function (data) {
+57 -361
View File
@@ -1,13 +1,19 @@
window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating',
props: ['rating']
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: {
amount: {
type: Number,
default: 0
}
},
template: '<span>{{ fsat }}</span>',
computed: {
fsat: function () {
return LNbits.utils.formatSat(this.amount)
@@ -16,6 +22,7 @@ window.app.component('lnbits-fsat', {
})
window.app.component('lnbits-wallet-list', {
template: '#lnbits-wallet-list',
props: ['balance'],
data: function () {
return {
@@ -27,52 +34,6 @@ window.app.component('lnbits-wallet-list', {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
template: `
<q-list v-if="user && user.wallets.length" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('wallets')"></q-item-label>
<q-item v-for="wallet in wallets" :key="wallet.id"
clickable
:active="activeWallet && activeWallet.id === wallet.id"
tag="a" :href="wallet.url">
<q-item-section side>
<q-avatar size="md"
:color="(activeWallet && activeWallet.id === wallet.id)
? (($q.dark.isActive) ? 'primary' : 'primary')
: 'grey-5'">
<q-icon name="flash_on" :size="($q.dark.isActive) ? '21px' : '20px'"
:color="($q.dark.isActive) ? 'blue-grey-10' : 'grey-3'"></q-icon>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1">{{ wallet.name }}</q-item-label>
<q-item-label v-if="LNBITS_DENOMINATION != 'sats'" caption>{{ parseFloat(String(wallet.live_fsat).replaceAll(",", "")) / 100 }} {{ LNBITS_DENOMINATION }}</q-item-label>
<q-item-label v-else caption>{{ wallet.live_fsat }} {{ LNBITS_DENOMINATION }}</q-item-label>
</q-item-section>
<q-item-section side v-show="activeWallet && activeWallet.id === wallet.id">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item clickable @click="showForm = !showForm">
<q-item-section side>
<q-icon :name="(showForm) ? 'remove' : 'add'" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption" v-text="$t('add_wallet')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showForm">
<q-item-section>
<q-form @submit="createWallet">
<q-input filled dense v-model="walletName" label="Name wallet *">
<template v-slot:append>
<q-btn round dense flat icon="send" size="sm" @click="createWallet" :disable="walletName === ''"></q-btn>
</template>
</q-input>
</q-form>
</q-item-section>
</q-item>
</q-list>
`,
computed: {
wallets: function () {
var bal = this.balance
@@ -102,37 +63,13 @@ window.app.component('lnbits-wallet-list', {
})
window.app.component('lnbits-extension-list', {
template: '#lnbits-extension-list',
data: function () {
return {
extensions: [],
user: null
}
},
template: `
<q-list v-if="user && userExtensions.length > 0" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('extensions')"></q-item-label>
<q-item v-for="extension in userExtensions" :key="extension.code"
clickable
:active="extension.isActive"
tag="a" :href="extension.url">
<q-item-section side>
<q-avatar size="md">
<q-img
:src="extension.tile"
style="max-width:20px"
></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1">{{ extension.name }} </q-item-label>
</q-item-section>
<q-item-section side v-show="extension.isActive">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<div class="lt-md q-mt-xl q-mb-xl"></div>
</q-list>
`,
computed: {
userExtensions: function () {
if (!this.user) return []
@@ -168,59 +105,20 @@ window.app.component('lnbits-extension-list', {
})
window.app.component('lnbits-manage', {
template: '#lnbits-manage',
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
methods: {
isActive: function (path) {
return window.location.pathname === path
}
},
data: function () {
data() {
return {
extensions: [],
user: null
}
},
template: `
<q-list v-if="user" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('manage')"></q-item-label>
<div v-if="user.admin">
<q-item v-if='showAdmin' clickable tag="a" href="/admin" :active="isActive('/admin')">
<q-item-section side>
<q-icon name="admin_panel_settings" :color="isActive('/admin') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('server')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if='showNode' clickable tag="a" href="/node" :active="isActive('/node')">
<q-item-section side>
<q-icon name="developer_board" :color="isActive('/node') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('node')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showUsers" clickable tag="a" href="/users" :active="isActive('/users')">
<q-item-section side>
<q-icon name="groups" :color="isActive('/users') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('users')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item v-if="showExtensions" clickable tag="a" href="/extensions" :active="isActive('/extensions')">
<q-item-section side>
<q-icon name="extension" :color="isActive('/extensions') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('extensions')"></q-item-label>
</q-item-section>
</q-item>
</q-list>
`,
created: function () {
created() {
if (window.user) {
this.user = LNbits.map.user(window.user)
}
@@ -228,6 +126,7 @@ window.app.component('lnbits-manage', {
})
window.app.component('lnbits-payment-details', {
template: '#lnbits-payment-details',
props: ['payment'],
mixins: [window.windowMixin],
data: function () {
@@ -235,72 +134,6 @@ window.app.component('lnbits-payment-details', {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
template: `
<div class="q-py-md" style="text-align: left">
<div v-if="payment.tag" class="row justify-center q-mb-md">
<q-badge v-if="hasTag" color="yellow" text-color="black">
#{{ payment.tag }}
</q-badge>
</div>
<div class="row">
<b v-text="$t('created')"></b>:
{{ payment.date }} ({{ payment.dateFrom }})
</div>
<div class="row" v-if="hasExpiry">
<b v-text="$t('expiry')"></b>:
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
</div>
<div class="row">
<b v-text="$t('amount')"></b>:
{{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="row">
<b v-text="$t('fee')"></b>:
{{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('payment_hash')"></b>:&nbsp;{{ payment.payment_hash }}
<q-icon name="content_copy" @click="copyText(payment.payment_hash)" size="1em" color="grey" class="q-mb-xs cursor-pointer" />
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('memo')"></b>:&nbsp;{{ payment.memo }}
</div>
<div class="text-wrap" v-if="payment.webhook">
<b style="white-space: nowrap;" v-text="$t('webhook')"></b>:&nbsp;{{ payment.webhook }}:&nbsp;<q-badge :color="webhookStatusColor" text-color="white">
{{ webhookStatusText }}
</q-badge>
</div>
<div class="text-wrap" v-if="hasPreimage">
<b style="white-space: nowrap;" v-text="$t('payment_proof')"></b>:&nbsp;{{ payment.preimage }}
</div>
<div class="row" v-for="entry in extras">
<q-badge v-if="hasTag" color="secondary" text-color="white">
extra
</q-badge>
<b>{{ entry.key }}</b>:
{{ entry.value }}
</div>
<div class="row" v-if="hasSuccessAction">
<b>Success action</b>:
<lnbits-lnurlpay-success-action
:payment="payment"
:success_action="payment.extra.success_action"
></lnbits-lnurlpay-success-action>
</div>
</div>
`,
computed: {
hasPreimage() {
return (
@@ -344,26 +177,15 @@ window.app.component('lnbits-payment-details', {
})
window.app.component('lnbits-lnurlpay-success-action', {
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'],
data() {
return {
decryptedValue: this.success_action.ciphertext
}
},
template: `
<div>
<p class="q-mb-sm">{{ success_action.message || success_action.description }}</p>
<code v-if="decryptedValue" class="text-h6 q-mt-sm q-mb-none">
{{ decryptedValue }}
</code>
<p v-else-if="success_action.url" class="text-h6 q-mt-sm q-mb-none">
<a target="_blank" style="color: inherit;" :href="success_action.url">{{ success_action.url }}</a>
</p>
</div>
`,
mounted: function () {
if (this.success_action.tag !== 'aes') return null
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
value => {
this.decryptedValue = value
@@ -373,6 +195,7 @@ window.app.component('lnbits-lnurlpay-success-action', {
})
window.app.component('lnbits-qrcode', {
template: '#lnbits-qrcode',
mixins: [window.windowMixin],
components: {
QrcodeVue
@@ -382,16 +205,11 @@ window.app.component('lnbits-qrcode', {
return {
logo: LNBITS_QR_LOGO
}
},
template: `
<div class="qrcode__wrapper">
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
</div>
`
}
})
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
@@ -402,26 +220,6 @@ window.app.component('lnbits-notifications-btn', {
isPermissionDenied: false
}
},
template: `
<q-btn
v-if="g.user.wallets"
:disabled="!this.isSupported"
dense
flat
round
@click="toggleNotifications()"
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
size="sm"
type="a"
>
<q-tooltip v-if="this.isSupported && !this.isSubscribed">Subscribe to notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isSubscribed">Unsubscribe from notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
Notifications are disabled,<br/>please enable or reset permissions
</q-tooltip>
<q-tooltip v-if="!this.isSupported">Notifications are not supported</q-tooltip>
</q-btn>
`,
methods: {
// converts base64 to Array buffer
urlB64ToUint8Array(base64String) {
@@ -605,119 +403,15 @@ window.app.component('lnbits-notifications-btn', {
})
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'value'],
props: ['options', 'modelValue'],
data() {
return {
formData: null,
rules: [val => !!val || 'Field is required']
}
},
template: `
<div v-if="formData">
<div class="row q-mb-lg" v-for="o in options">
<div class="col auto-width">
<p v-if=o.options?.length class="q-ml-xl">
<span v-text="o.label || o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
</p>
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
@input="handleValueChanged" class="q-ml-xl">
</lnbits-dynamic-fields>
<div v-else>
<q-input
v-if="o.type === 'number'"
type="number"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'text'"
type="textarea"
rows="5"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'password'"
v-model="formData[o.name]"
@input="handleValueChanged"
type="password"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-select
v-else-if="o.type === 'select'"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:options="o.values"
:rules="applyRules(o.required)"
></q-select>
<q-select
v-else-if="o.isList"
v-model.trim="formData[o.name]"
@input="handleValueChanged"
input-debounce="0"
new-value-mode="add-unique"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
multiple
dense
use-input
use-chips
multiple
hide-dropdown-icon
></q-select>
<div v-else-if="o.type === 'bool'">
<q-item tag="label" v-ripple>
<q-item-section avatar top>
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
</q-item-section>
<q-item-section>
<q-item-label><span v-text="o.label || o.name"></span></q-item-label>
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
</q-item-section>
</q-item>
</div>
<q-input
v-else-if="o.type === 'hidden'"
v-model="formData[o.name]"
type="text"
style="display: none"
:rules="applyRules(o.required)"
></q-input>
<q-input
v-else
v-model="formData[o.name]"
@input="handleValueChanged"
:hint="o.description"
:label="o.label || o.name"
:rules="applyRules(o.required)"
filled
dense
></q-input>
</div>
</div>
</div>
</div>
`,
methods: {
applyRules(required) {
return required ? this.rules : []
@@ -733,15 +427,47 @@ window.app.component('lnbits-dynamic-fields', {
}, {})
},
handleValueChanged() {
this.$emit('input', this.formData)
this.$emit('update:model-value', this.formData)
}
},
created: function () {
this.formData = this.buildData(this.options, this.value)
created() {
this.formData = this.buildData(this.options, this.modelValue)
}
})
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
chip: '',
chips: []
}
},
methods: {
addChip() {
if (!this.chip) return
this.chips.push(this.chip)
this.chip = ''
this.$emit('update:model-value', this.chips.join(','))
},
removeChip(index) {
this.chips.splice(index, 1)
this.$emit('update:model-value', this.chips.join(','))
}
},
created() {
if (typeof this.modelValue === 'string') {
this.chips = this.modelValue.split(',')
} else {
this.chips = [...this.modelValue]
}
}
})
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'callback'],
computed: {
@@ -749,7 +475,7 @@ window.app.component('lnbits-update-balance', {
return LNBITS_DENOMINATION
},
admin() {
return this.g.user.admin
return user.super_user
}
},
data: function () {
@@ -773,7 +499,7 @@ window.app.component('lnbits-update-balance', {
})
.then(_ => {
credit = parseInt(credit)
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: this.$t('wallet_topup_ok', {
amount: credit
@@ -786,35 +512,5 @@ window.app.component('lnbits-update-balance', {
LNbits.utils.notifyApiError(error)
})
}
},
template: `
<q-btn
v-if="admin"
round
color="primary"
icon="add"
size="sm"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
filled
:label='$t("credit_label", { denomination: denomination })'
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip>
</q-btn>
`
}
})
@@ -1,16 +0,0 @@
window.app.component('lnbits-extension-rating', {
name: 'lnbits-extension-rating',
props: ['rating'],
template: `
<div style="margin-bottom: 3px">
<q-rating
v-model="rating"
size="1.5em"
:max="5"
color="primary"
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
</div>
`
})
@@ -1,10 +1,11 @@
window.app.component('lnbits-extension-settings-form', {
name: 'lnbits-extension-settings-form',
template: '#lnbits-extension-settings-form',
props: ['options', 'adminkey', 'endpoint'],
methods: {
async updateSettings() {
if (!this.settings) {
return this.$q.notify({
return Quasar.Notify.create({
message: 'No settings to update',
type: 'negative'
})
@@ -49,16 +50,6 @@ window.app.component('lnbits-extension-settings-form', {
created: async function () {
await this.getSettings()
},
template: `
<q-form v-if="settings" @submit="updateSettings" class="q-gutter-md">
<lnbits-dynamic-fields :options="options" v-model="settings"></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup unelevated color="danger" @click="resetSettings" >Reset</q-btn>
<slot name="actions"></slot>
</div>
</q-form>
`,
data: function () {
return {
settings: undefined
@@ -67,21 +58,9 @@ window.app.component('lnbits-extension-settings-form', {
})
window.app.component('lnbits-extension-settings-btn-dialog', {
template: '#lnbits-extension-settings-btn-dialog',
name: 'lnbits-extension-settings-btn-dialog',
props: ['options', 'adminkey', 'endpoint'],
template: `
<q-btn v-if="options" unelevated @click="show = true" color="primary" icon="settings" class="float-right">
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<lnbits-extension-settings-form :options="options" :adminkey="adminkey" :endpoint="endpoint">
<template v-slot:actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</template>
</lnbits-extension-settings-form>
</q-card>
</q-dialog>
</q-btn>
`,
data: function () {
return {
show: false
@@ -1,4 +1,5 @@
window.app.component('lnbits-funding-sources', {
template: '#lnbits-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
@@ -196,45 +197,5 @@ window.app.component('lnbits-funding-sources', {
]
]
}
},
template: `
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
<q-select
filled
v-model="formData.lnbits_backend_wallet_class"
hint="Select the active funding wallet"
:options="sortedAllowedFundingSources"
:option-label="(item) => getFundingSourceLabel(item)"
></q-select>
</div>
</div>
<q-list
class="q-mt-md"
v-for="(fund, idx) in allowedFundingSources"
:key="idx"
>
<div v-if="fundingSources.get(fund) && fund === formData.lnbits_backend_wallet_class">
<div class="row"
v-for="([key, prop], i) in Object.entries(fundingSources.get(fund))"
:key="i"
>
<div class="col-12">
<q-input
filled
type="text"
class="q-mt-sm"
v-model="formData[key]"
:label="prop.label"
:hint="prop.hint"
></q-input>
</div>
</div>
</div>
</q-list>
</div>
`
}
})
+4 -28
View File
@@ -35,14 +35,14 @@ function generateChart(canvas, rawData) {
type: 'bar',
label: 'in',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
backgroundColor: 'rgba(76, 175, 80, 0.5)' // green
},
{
data: data.spending,
type: 'bar',
label: 'out',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
backgroundColor: 'rgba(233, 30, 99, 0.5)' // pink
}
]
},
@@ -81,6 +81,7 @@ function generateChart(canvas, rawData) {
}
window.app.component('payment-chart', {
template: '#payment-chart',
name: 'payment-chart',
props: ['wallet'],
mixins: [window.windowMixin],
@@ -128,30 +129,5 @@ window.app.component('payment-chart', {
this.paymentsChart.show = false
})
}
},
template: `
<span id="payment-chart">
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart" >
<q-tooltip>
<span v-text="$t('chart_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select label="Group" filled dense v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
</span>
`
}
})
+2 -276
View File
@@ -1,5 +1,6 @@
window.app.component('payment-list', {
name: 'payment-list',
template: '#payment-list',
props: ['update', 'wallet', 'mobileSimple', 'lazy'],
mixins: [window.windowMixin],
data: function () {
@@ -223,280 +224,5 @@ window.app.component('payment-list', {
},
created: function () {
if (this.lazy === undefined) this.fetchPayments()
},
template: `
<q-card
:style="$q.screen.lt.md ? {
background: $q.screen.lt.md ? 'none !important': ''
, boxShadow: $q.screen.lt.md ? 'none !important': ''
, marginTop: $q.screen.lt.md ? '0px !important': ''
} : ''"
>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn
@click="addFilterTag"
dense
flat
icon="add"
></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-btn v-close-popup outline color="grey" @click="exportCSV(true)" label="Export to CSV with details" ></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
<q-input
:style="$q.screen.lt.md ? {
display: mobileSimple ? 'none !important': ''
} : ''"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip
><span>failed</span
></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip
><span v-text="$t('pending')"></span
></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge
v-if="props.row.tag"
color="yellow"
text-color="black"
>
<a
v-text="'#'+props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip
><span v-text="props.row.date"></span
></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="denomination != 'sats'"
:props="props"
class="col1"
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
>
</q-td>
<q-td class="col2" auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="formatCurrency(props.row.extra.wallet_fiat_amount, props.row.extra.wallet_fiat_currency)"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="formatCurrency(props.row.extra.fiat_amount, props.row.extra.fiat_currency)"
></span>
</i>
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
`
}
})
+94 -1
View File
@@ -13,6 +13,7 @@ window.app = Vue.createApp({
authMethod: 'username-password',
usr: '',
username: '',
reset_key: '',
email: '',
password: '',
passwordRepeat: '',
@@ -42,6 +43,79 @@ window.app = Vue.createApp({
this.authAction = 'register'
this.authMethod = authMethod
},
signInWithNostr: async function () {
try {
const nostrToken = await this.createNostrToken()
if (!nostrToken) {
return
}
resp = await LNbits.api.loginByProvider(
'nostr',
{Authorization: nostrToken},
{}
)
window.location.href = '/wallet'
} catch (error) {
console.warn(error)
const details = error?.response?.data?.detail || `${error}`
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign in with Nostr.',
caption: details
})
}
},
createNostrToken: async function () {
try {
async function _signEvent(e) {
try {
const {data} = await LNbits.api.getServerHealth()
e.created_at = data.server_time
return await window.nostr.signEvent(e)
} catch (error) {
console.error(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign nostr event.',
caption: `${error}`
})
}
}
if (!window.nostr?.signEvent) {
Quasar.Notify.create({
type: 'negative',
message: 'No Nostr signing app detected.',
caption: 'Is "window.nostr" present?'
})
return
}
const tagU = `${window.location}nostr`
const tagMethod = 'POST'
const nostrToken = await NostrTools.nip98.getToken(
tagU,
tagMethod,
e => _signEvent(e),
true
)
const isTokenValid = await NostrTools.nip98.validateToken(
nostrToken,
tagU,
tagMethod
)
if (!isTokenValid) {
throw new Error('Invalid signed token!')
}
return nostrToken
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed create Nostr event.',
caption: `${error}`
})
}
},
register: async function () {
try {
await LNbits.api.register(
@@ -55,6 +129,18 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
reset: async function () {
try {
await LNbits.api.reset(
this.reset_key,
this.password,
this.passwordRepeat
)
window.location.href = '/wallet'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
login: async function () {
try {
await LNbits.api.login(this.username, this.password)
@@ -69,6 +155,7 @@ window.app = Vue.createApp({
this.usr = ''
window.location.href = '/wallet'
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},
@@ -78,7 +165,7 @@ window.app = Vue.createApp({
})
},
processing: function () {
this.$q.notify({
Quasar.Notify.create({
timeout: 0,
message: 'Processing...',
icon: null
@@ -97,5 +184,11 @@ window.app = Vue.createApp({
if (this.isUserAuthorized) {
window.location.href = '/wallet'
}
this.reset_key = new URLSearchParams(window.location.search).get(
'reset_key'
)
if (this.reset_key) {
this.authAction = 'reset'
}
}
})
+39 -30
View File
@@ -165,32 +165,22 @@ window.app = Vue.createApp({
type: 'bubble',
options: {
scales: {
xAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'Tx count'
}
x: {
type: 'linear',
beginAtZero: true,
title: {
text: 'Transaction count'
}
],
yAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'User balance in million sats'
}
},
y: {
type: 'linear',
beginAtZero: true,
title: {
text: 'User balance in million sats'
}
]
}
},
tooltips: {
tooltip: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
@@ -215,15 +205,34 @@ window.app = Vue.createApp({
})
},
methods: {
formatDate: function (value) {
return LNbits.utils.formatDate(value)
},
formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000))
},
resetPassword(user_id) {
return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
.then(res => {
this.$q.notify({
type: 'positive',
message: 'generated key for password reset',
icon: null
})
const url = window.location.origin + '?reset_key=' + res.data
this.copyText(url)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createUser() {
LNbits.api
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
.then(() => {
this.fetchUsers()
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! User created!',
icon: null
@@ -243,7 +252,7 @@ window.app = Vue.createApp({
)
.then(() => {
this.fetchUsers()
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! User created!',
icon: null
@@ -261,7 +270,7 @@ window.app = Vue.createApp({
.request('DELETE', `/users/api/v1/user/${user_id}`)
.then(() => {
this.fetchUsers()
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! User deleted!',
icon: null
@@ -280,7 +289,7 @@ window.app = Vue.createApp({
)
.then(() => {
this.fetchWallets(user_id)
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! Undeleted user wallet!',
icon: null
@@ -299,7 +308,7 @@ window.app = Vue.createApp({
.request('DELETE', `/users/api/v1/user/${user_id}/wallet/${wallet}`)
.then(() => {
this.fetchWallets(user_id)
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! User wallet deleted!',
icon: null
@@ -379,7 +388,7 @@ window.app = Vue.createApp({
.request('GET', `/users/api/v1/user/${user_id}/admin`)
.then(() => {
this.fetchUsers()
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Success! Toggled admin!',
icon: null
@@ -411,7 +420,7 @@ window.app = Vue.createApp({
this.wallet
)
.then(_ => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`,
icon: null
+30 -25
View File
@@ -5,7 +5,9 @@ window.app = Vue.createApp({
return {
updatePayments: false,
origin: window.location.origin,
wallet: LNbits.map.wallet(window.wallet),
user: LNbits.map.user(window.user),
exportUrl: `${window.location.origin}/wallet?usr=${window.user.id}&wal=${window.wallet.id}`,
receive: {
show: false,
status: 'pending',
@@ -142,9 +144,11 @@ window.app = Vue.createApp({
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentReq = response.data.bolt11
this.receive.paymentHash = response.data.payment_hash
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
@@ -152,7 +156,7 @@ window.app = Vue.createApp({
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
@@ -161,7 +165,7 @@ window.app = Vue.createApp({
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
Quasar.Notify.create({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
@@ -200,7 +204,7 @@ window.app = Vue.createApp({
? mapping[valid_error]
: `ERROR: Camera error (${error.name})`
this.parse.camera.show = false
this.$q.notify({
Quasar.Notify.create({
message: camera_error,
type: 'negative'
})
@@ -220,7 +224,7 @@ window.app = Vue.createApp({
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
@@ -255,7 +259,7 @@ window.app = Vue.createApp({
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.parse.data.request = res[0].rawValue
this.decodeRequest()
this.parse.camera.show = false
},
@@ -290,7 +294,7 @@ window.app = Vue.createApp({
try {
invoice = decode(this.parse.data.request)
} catch (error) {
this.$q.notify({
Quasar.Notify.create({
timeout: 3000,
type: 'warning',
message: error + '.',
@@ -316,7 +320,7 @@ window.app = Vue.createApp({
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = this.$q.utils.date.formatDate(
cleanInvoice.expireDate = Quasar.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
@@ -328,7 +332,7 @@ window.app = Vue.createApp({
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
let dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: this.$t('processing_payment')
})
@@ -361,7 +365,7 @@ window.app = Vue.createApp({
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
let dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: 'Processing payment...'
})
@@ -391,12 +395,13 @@ window.app = Vue.createApp({
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url':
this.$q.notify({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -404,8 +409,8 @@ window.app = Vue.createApp({
})
break
case 'message':
this.$q.notify({
message: response.data.success_action.message,
Quasar.Notify.create({
message: extra.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
@@ -416,14 +421,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
response.data.success_action,
extra.success_action,
payment.preimage
)
)
.then(value => {
this.$q.notify({
Quasar.Notify.create({
message: value,
caption: response.data.success_action.description,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -443,7 +448,7 @@ window.app = Vue.createApp({
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
let dismissAuthMsg = Quasar.Notify.create({
timeout: 10,
message: 'Performing authentication...'
})
@@ -452,7 +457,7 @@ window.app = Vue.createApp({
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.then(_ => {
dismissAuthMsg()
this.$q.notify({
Quasar.Notify.create({
message: `Authentication successful.`,
type: 'positive',
timeout: 3500
@@ -462,7 +467,7 @@ window.app = Vue.createApp({
.catch(err => {
dismissAuthMsg()
if (err.response.data.reason) {
this.$q.notify({
Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
caption: err.response.data.reason,
type: 'warning',
@@ -477,7 +482,7 @@ window.app = Vue.createApp({
LNbits.api
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(_ => {
this.$q.notify({
Quasar.Notify.create({
message: `Wallet updated.`,
type: 'positive',
timeout: 3500
@@ -495,7 +500,7 @@ window.app = Vue.createApp({
LNbits.api
.deleteWallet(this.g.wallet)
.then(_ => {
this.$q.notify({
Quasar.Notify.create({
timeout: 3000,
message: `Wallet deleted!`,
spinner: true
+10 -9
View File
@@ -207,23 +207,24 @@ video {
}
// qrcode
.qrcode__wrapper canvas {
.qrcode__wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important; // important to override qrcode inline width
max-width: 100%;
max-height: 100%;
height: 100% !important;
max-width: 350px; // default width of <lnbits-qrcode> component
}
.qrcode__image {
position: absolute;
max-width: 52px;
width: 15%;
height: 15%;
overflow: hidden;
background: #fff;
left: 50%;
overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem;
border-radius: 0.2rem;
}
+1 -1
View File
@@ -12,6 +12,7 @@
"vendor/qrcode.vue.browser.js",
"vendor/chart.umd.js",
"vendor/showdown.js",
"vendor/nostr.bundle.js",
"i18n/i18n.js",
"i18n/de.js",
"i18n/en.js",
@@ -39,7 +40,6 @@
"components": [
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/extension-rating.js",
"js/components/payment-list.js",
"js/components/payment-chart.js",
"js/components.js",
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -3190,13 +3190,13 @@ body.platform-ios .q-layout--containerized {
position: fixed !important;
display: inline-block;
max-width: 95vw;
max-height: 65vh;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2), 0 2px 2px rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12);
background: #fff;
border-radius: 4px;
overflow-y: auto;
overflow-x: hidden;
outline: 0;
max-height: 65vh;
z-index: 6000;
}
.q-menu--square {
@@ -5619,6 +5619,8 @@ body.desktop .q-toggle--dense:not(.disabled):focus .q-toggle__thumb:before, body
overflow-y: auto;
overflow-x: hidden;
padding: 6px 10px;
max-width: 95vw;
max-height: 65vh;
}
@media (max-width: 599.98px) {
.q-tooltip {
@@ -5900,7 +5902,6 @@ body.desktop .q-toggle--dense:not(.disabled):focus .q-toggle__thumb:before, body
height: 200px;
min-width: 200px;
background-position: 50% 50%;
background-size: cover;
background-repeat: no-repeat;
}
.q-uploader__file--img:before {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40 -22
View File
@@ -1,5 +1,5 @@
/*!
* vue-router v4.4.3
* vue-router v4.4.5
* (c) 2024 Eduardo San Martin Morote
* @license MIT
*/
@@ -8,8 +8,24 @@ var VueRouter = (function (exports, vue) {
const isBrowser = typeof document !== 'undefined';
/**
* Allows differentiating lazy components from functional components and vue-class-component
* @internal
*
* @param component
*/
function isRouteComponent(component) {
return (typeof component === 'object' ||
'displayName' in component ||
'props' in component ||
'__vccOpts' in component);
}
function isESModule(obj) {
return obj.__esModule || obj[Symbol.toStringTag] === 'Module';
return (obj.__esModule ||
obj[Symbol.toStringTag] === 'Module' ||
// support CF with dynamic imports that do not
// add the Module string tag
(obj.default && isRouteComponent(obj.default)));
}
const assign = Object.assign;
function applyToParams(fn, params) {
@@ -1412,13 +1428,14 @@ var VueRouter = (function (exports, vue) {
mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record;
const options = mergeOptions(globalOptions, record);
// generate an array of records to correctly handle aliases
const normalizedRecords = [
mainNormalizedRecord,
];
const normalizedRecords = [mainNormalizedRecord];
if ('alias' in record) {
const aliases = typeof record.alias === 'string' ? [record.alias] : record.alias;
for (const alias of aliases) {
normalizedRecords.push(assign({}, mainNormalizedRecord, {
normalizedRecords.push(
// we need to normalize again to ensure the `mods` property
// being non enumerable
normalizeRouteRecord(assign({}, mainNormalizedRecord, {
// this allows us to hold a copy of the `components` option
// so that async components cache is hold on the original record
components: originalRecord
@@ -1431,7 +1448,7 @@ var VueRouter = (function (exports, vue) {
: mainNormalizedRecord,
// the aliases are always of the same kind as the original since they
// are defined on the same record
}));
})));
}
}
let matcher;
@@ -1642,12 +1659,12 @@ var VueRouter = (function (exports, vue) {
* @returns the normalized version
*/
function normalizeRouteRecord(record) {
return {
const normalized = {
path: record.path,
redirect: record.redirect,
name: record.name,
meta: record.meta || {},
aliasOf: undefined,
aliasOf: record.aliasOf,
beforeEnter: record.beforeEnter,
props: normalizeRecordProps(record),
children: record.children || [],
@@ -1655,10 +1672,19 @@ var VueRouter = (function (exports, vue) {
leaveGuards: new Set(),
updateGuards: new Set(),
enterCallbacks: {},
// must be declared afterwards
// mods: {},
components: 'components' in record
? record.components || null
: record.component && { default: record.component },
};
// mods contain modules and shouldn't be copied,
// logged or anything. It's just used for internal
// advanced use cases like data loaders
Object.defineProperty(normalized, 'mods', {
value: {},
});
return normalized;
}
/**
* Normalize the optional `props` in a record to always be an object similar to
@@ -2148,10 +2174,12 @@ var VueRouter = (function (exports, vue) {
}
guards.push(() => componentPromise.then(resolved => {
if (!resolved)
return Promise.reject(new Error(`Couldn't resolve component "${name}" at "${record.path}"`));
throw new Error(`Couldn't resolve component "${name}" at "${record.path}"`);
const resolvedComponent = isESModule(resolved)
? resolved.default
: resolved;
// keep the resolved module for plugins like data loaders
record.mods[name] = resolved;
// replace the function with the resolved component
// cannot be null or undefined because we went into the for loop
record.components[name] = resolvedComponent;
@@ -2166,18 +2194,6 @@ var VueRouter = (function (exports, vue) {
}
return guards;
}
/**
* Allows differentiating lazy components from functional components and vue-class-component
* @internal
*
* @param component
*/
function isRouteComponent(component) {
return (typeof component === 'object' ||
'displayName' in component ||
'props' in component ||
'__vccOpts' in component);
}
/**
* Ensures a route is loaded, so it can be passed as o prop to `<RouterView>`.
*
@@ -2197,6 +2213,8 @@ var VueRouter = (function (exports, vue) {
const resolvedComponent = isESModule(resolved)
? resolved.default
: resolved;
// keep the resolved module for plugins like data loaders
record.mods[name] = resolved;
// replace the function with the resolved component
// cannot be null or undefined because we went into the for loop
record.components[name] = resolvedComponent;
File diff suppressed because one or more lines are too long
+12 -20
View File
@@ -20,8 +20,7 @@ from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
@@ -85,7 +84,7 @@ async def catch_everything_and_restart(
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return catch_everything_and_restart(func, name)
return await catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -181,17 +180,14 @@ async def check_pending_payments():
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
payment.status = PaymentState.FAILED
await update_payment(payment)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
@@ -211,14 +207,10 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
+2 -6
View File
@@ -1,5 +1,4 @@
<!doctype html>
<html lang="en">
<head>
{% for url in INCLUDED_CSS %}
@@ -30,7 +29,6 @@
<link async="async" rel="manifest" href="{{ web_manifest }}" />
{% endif %} {% block head_scripts %}{% endblock %}
</head>
<body data-theme="bitcoin">
<div id="vue">
<q-layout view="hHh lpR lfr" v-cloak>
@@ -225,12 +223,10 @@
</q-layout>
</div>
{% block vue_templates %}{% endblock %}
<!---->
{% for url in INCLUDED_JS %}
{% include('components.vue') %} {% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
<!---->
<script type="text/javascript">
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
+937
View File
@@ -0,0 +1,937 @@
<template id="lnbits-wallet-list">
<q-list
v-if="user && user.wallets.length"
dense
class="lnbits-drawer__q-list"
>
<q-item-label header v-text="$t('wallets')"></q-item-label>
<q-item
v-for="wallet in wallets"
:key="wallet.id"
clickable
:active="activeWallet && activeWallet.id === wallet.id"
tag="a"
:href="wallet.url"
>
<q-item-section side>
<q-avatar
size="md"
:color="
activeWallet && activeWallet.id === wallet.id
? $q.dark.isActive
? 'primary'
: 'primary'
: 'grey-5'
"
>
<q-icon
name="flash_on"
:size="$q.dark.isActive ? '21px' : '20px'"
:color="$q.dark.isActive ? 'blue-grey-10' : 'grey-3'"
></q-icon>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1"
><span v-text="wallet.name"></span
></q-item-label>
<q-item-label v-if="LNBITS_DENOMINATION != 'sats'" caption>
<span
v-text="
parseFloat(String(wallet.live_fsat).replaceAll(',', '')) / 100
"
></span
>&nbsp;
<span v-text="LNBITS_DENOMINATION"></span>
</q-item-label>
<q-item-label v-else caption>
<span v-text="wallet.live_fsat"></span>&nbsp;
<span v-text="LNBITS_DENOMINATION"></span>
</q-item-label>
</q-item-section>
<q-item-section
side
v-show="activeWallet && activeWallet.id === wallet.id"
>
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item clickable @click="showForm = !showForm">
<q-item-section side>
<q-icon
:name="showForm ? 'remove' : 'add'"
color="grey-5"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label
lines="1"
class="text-caption"
v-text="$t('add_wallet')"
></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showForm">
<q-item-section>
<q-form @submit="createWallet">
<q-input filled dense v-model="walletName" label="Name wallet *">
<template v-slot:append>
<q-btn
round
dense
flat
icon="send"
size="sm"
@click="createWallet"
:disable="walletName === ''"
></q-btn>
</template>
</q-input>
</q-form>
</q-item-section>
</q-item>
</q-list>
</template>
<template id="lnbits-extension-list">
<q-list
v-if="user && userExtensions.length > 0"
dense
class="lnbits-drawer__q-list"
>
<q-item-label header v-text="$t('extensions')"></q-item-label>
<q-item
v-for="extension in userExtensions"
:key="extension.code"
clickable
:active="extension.isActive"
tag="a"
:href="extension.url"
>
<q-item-section side>
<q-avatar size="md">
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1"
><span v-text="extension.name"></span>
</q-item-label>
</q-item-section>
<q-item-section side v-show="extension.isActive">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<div class="lt-md q-mt-xl q-mb-xl"></div>
</q-list>
</template>
<template id="lnbits-manage">
<q-list v-if="user" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('manage')"></q-item-label>
<div v-if="user.admin">
<q-item
v-if="showAdmin"
clickable
tag="a"
href="/admin"
:active="isActive('/admin')"
>
<q-item-section side>
<q-icon
name="admin_panel_settings"
:color="isActive('/admin') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('server')"></q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="showNode"
clickable
tag="a"
href="/node"
:active="isActive('/node')"
>
<q-item-section side>
<q-icon
name="developer_board"
:color="isActive('/node') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('node')"></q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="showUsers"
clickable
tag="a"
href="/users"
:active="isActive('/users')"
>
<q-item-section side>
<q-icon
name="groups"
:color="isActive('/users') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('users')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item
v-if="showExtensions"
clickable
tag="a"
href="/extensions"
:active="isActive('/extensions')"
>
<q-item-section side>
<q-icon
name="extension"
:color="isActive('/extensions') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('extensions')"></q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<template id="lnbits-payment-details">
<div class="q-py-md" style="text-align: left">
<div v-if="payment.tag" class="row justify-center q-mb-md">
<q-badge v-if="hasTag" color="yellow" text-color="black">
#<span v-text="payment.tag"></span>
</q-badge>
</div>
<div class="row">
<b v-text="$t('created')"></b>:
<span v-text="payment.date"></span>
(<span v-text="payment.dateFrom"></span>)
</div>
<div class="row" v-if="hasExpiry">
<b v-text="$t('expiry')"></b>:
<span v-text="payment.expirydate"></span>
(<span v-text="payment.expirydateFrom"></span>)
</div>
<div class="row">
<b v-text="$t('amount')"></b>:
<span v-text="(payment.amount / 1000).toFixed(3)"></span>
<span v-text="LNBITS_DENOMINATION"></span>
</div>
<div class="row">
<b v-text="$t('fee')"></b>:
<span v-text="(payment.fee / 1000).toFixed(3)"></span>
<span v-text="LNBITS_DENOMINATION"></span>
</div>
<div class="text-wrap">
<b style="white-space: nowrap" v-text="$t('payment_hash')"></b>:&nbsp;
<span v-text="payment.payment_hash"></span>
<q-icon
name="content_copy"
@click="copyText(payment.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div class="text-wrap">
<b style="white-space: nowrap" v-text="$t('memo')"></b>:&nbsp;
<span v-text="payment.memo"></span>
</div>
<div class="text-wrap" v-if="payment.webhook">
<b style="white-space: nowrap" v-text="$t('webhook')"></b>:&nbsp;
<span v-text="payment.webhook"></span>:&nbsp;<q-badge
:color="webhookStatusColor"
text-color="white"
>
<span v-text="webhookStatusText"></span>
</q-badge>
</div>
<div class="text-wrap" v-if="hasPreimage">
<b style="white-space: nowrap" v-text="$t('payment_proof')"></b>:&nbsp;
<span v-text="payment.preimage"></span>
</div>
<div class="row" v-for="entry in extras">
<q-badge v-if="hasTag" color="secondary" text-color="white">
extra
</q-badge>
<b v-text="entry.key"></b>: <span v-text="entry.value"></span>
</div>
<div class="row" v-if="hasSuccessAction">
<b>Success action</b>:
<lnbits-lnurlpay-success-action
:payment="payment"
:success_action="payment.extra.success_action"
></lnbits-lnurlpay-success-action>
</div>
</div>
</template>
<template id="lnbits-dynamic-fields">
<div v-if="formData">
<div class="row q-mb-lg" v-for="o in options">
<div class="col auto-width">
<p v-if="o.options?.length" class="q-ml-xl">
<span v-text="o.label || o.name"></span>
<small v-if="o.description">
(<span v-text="o.description"></span>)</small
>
</p>
<lnbits-dynamic-fields
v-if="o.options?.length"
:options="o.options"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
class="q-ml-xl"
>
</lnbits-dynamic-fields>
<div v-else>
<q-input
v-if="o.type === 'number'"
type="number"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'text'"
type="textarea"
rows="5"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'password'"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
type="password"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-select
v-else-if="o.type === 'select'"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:options="o.values"
:rules="applyRules(o.required)"
></q-select>
<q-select
v-else-if="o.isList"
v-model.trim="formData[o.name]"
@update:model-value="handleValueChanged"
input-debounce="0"
new-value-mode="add-unique"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
multiple
dense
use-input
use-chips
multiple
hide-dropdown-icon
></q-select>
<div v-else-if="o.type === 'bool'">
<q-item tag="label" v-ripple>
<q-item-section avatar top>
<q-checkbox
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
/>
</q-item-section>
<q-item-section>
<q-item-label
><span v-text="o.label || o.name"></span
></q-item-label>
<q-item-label caption>
<span v-text="o.description"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<q-input
v-else-if="o.type === 'hidden'"
v-model="formData[o.name]"
type="text"
style="display: none"
:rules="applyRules(o.required)"
></q-input>
<div v-else-if="o.type === 'chips'">
<lnbits-dynamic-chips
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
></lnbits-dynamic-chips>
</div>
<q-input
v-else
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
:hint="o.description"
:label="o.label || o.name"
:rules="applyRules(o.required)"
filled
dense
></q-input>
</div>
</div>
</div>
</div>
</template>
<template id="lnbits-dynamic-chips">
<q-input
filled
v-model="chip"
@keydown.enter.prevent="addChip"
type="text"
label="wss://...."
hint="Add relays"
class="q-mb-md"
>
<q-btn @click="addChip" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="(chip, i) in chips"
:key="chip"
removable
@remove="removeChip(i)"
color="primary"
text-color="white"
:label="chip"
>
</q-chip>
</div>
</template>
<template id="lnbits-notifications-btn">
<q-btn
v-if="g.user.wallets"
:disabled="!this.isSupported"
dense
flat
round
@click="toggleNotifications()"
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
size="sm"
type="a"
>
<q-tooltip v-if="this.isSupported && !this.isSubscribed"
>Subscribe to notifications</q-tooltip
>
<q-tooltip v-if="this.isSupported && this.isSubscribed"
>Unsubscribe from notifications</q-tooltip
>
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
Notifications are disabled,<br />please enable or reset permissions
</q-tooltip>
<q-tooltip v-if="!this.isSupported"
>Notifications are not supported</q-tooltip
>
</q-btn>
</template>
<template id="lnbits-update-balance">
<q-btn v-if="admin" round color="primary" icon="add" size="sm">
<q-popup-edit class="bg-accent text-white" v-slot="scope" v-model="credit">
<q-input
filled
:label="$t('credit_label', {denomination: denomination})"
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip>
</q-btn>
</template>
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<qrcode-vue
:value="value"
level="Q"
render-as="svg"
margin="1"
size="350"
class="rounded-borders"
></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="qrcode icon" />
</div>
</template>
<template id="lnbits-lnurlpay-success-action">
<div>
<p
class="q-mb-sm"
v-text="success_action.message || success_action.description"
></p>
<code
v-if="decryptedValue"
class="text-h6 q-mt-sm q-mb-none"
v-text="decryptedValue"
></code>
<p v-else-if="success_action.url" class="text-h6 q-mt-sm q-mb-none">
<a
target="_blank"
style="color: inherit"
:href="success_action.url"
v-text="success_action.url"
></a>
</p>
</div>
</template>
<template id="payment-list">
<q-card
:style="
$q.screen.lt.md
? {
background: $q.screen.lt.md ? 'none !important' : '',
boxShadow: $q.screen.lt.md ? 'none !important' : '',
marginTop: $q.screen.lt.md ? '0px !important' : ''
}
: ''
"
>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn @click="addFilterTag" dense flat icon="add"></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-btn
v-close-popup
outline
color="grey"
@click="exportCSV(true)"
label="Export to CSV with details"
></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
<q-input
:style="
$q.screen.lt.md
? {
display: mobileSimple ? 'none !important' : ''
}
: ''
"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
v-model:pagination="paymentsTable.pagination"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip><span>failed</span></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip><span v-text="$t('pending')"></span></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
v-text="'#' + props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip><span v-text="props.row.date"></span></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="denomination != 'sats'"
:props="props"
class="col1"
v-text="
parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100
"
>
</q-td>
<q-td class="col2" auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.wallet_fiat_amount,
props.row.extra.wallet_fiat_currency
)
"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.fiat_amount,
props.row.extra.fiat_currency
)
"
></span>
</i>
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</template>
<template id="lnbits-extension-rating">
<div style="margin-bottom: 3px">
<q-rating v-model="rating" size="1.5em" :max="5" color="primary"
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
</div>
</template>
<template id="lnbits-extension-settings-form">
<q-form v-if="settings" @submit="updateSettings" class="q-gutter-md">
<lnbits-dynamic-fields
:options="options"
v-model="settings"
></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit"
>Update</q-btn
>
<q-btn v-close-popup unelevated color="danger" @click="resetSettings"
>Reset</q-btn
>
<slot name="actions"></slot>
</div>
</q-form>
</template>
<template id="lnbits-extension-settings-btn-dialog">
<q-btn
v-if="options"
unelevated
@click="show = true"
color="primary"
icon="settings"
class="float-right"
>
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<lnbits-extension-settings-form
:options="options"
:adminkey="adminkey"
:endpoint="endpoint"
>
<template v-slot:actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</template>
</lnbits-extension-settings-form>
</q-card>
</q-dialog>
</q-btn>
</template>
<template id="lnbits-funding-sources">
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
<q-select
filled
v-model="formData.lnbits_backend_wallet_class"
hint="Select the active funding wallet"
:options="sortedAllowedFundingSources"
:option-label="item => getFundingSourceLabel(item)"
></q-select>
</div>
</div>
<q-list
class="q-mt-md"
v-for="(fund, idx) in allowedFundingSources"
:key="idx"
>
<div
v-if="
fundingSources.get(fund) &&
fund === formData.lnbits_backend_wallet_class
"
>
<div
class="row"
v-for="([key, prop], i) in Object.entries(fundingSources.get(fund))"
:key="i"
>
<div class="col-12">
<q-input
filled
type="text"
class="q-mt-sm"
v-model="formData[key]"
:label="prop.label"
:hint="prop.hint"
></q-input>
</div>
</div>
</div>
</q-list>
</div>
</template>
<template id="payment-chart">
<span id="payment-chart">
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>
<span v-text="$t('chart_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select
label="Group"
filled
dense
v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
</span>
</template>
+17 -22
View File
@@ -39,26 +39,21 @@
</q-card-section>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
},
methods: {
goBack: function () {
window.history.back()
},
goHome: function () {
window.location.href = '/'
}
}
})
</script>
{% endblock %}
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
methods: {
goBack: function () {
window.history.back()
},
goHome: function () {
window.location.href = '/'
}
}
})
</script>
{% endblock %}
+2 -2
View File
@@ -5,10 +5,10 @@
window.currencies = {{ currencies | tojson | safe }};
{% endif %}
{% if user %}
window.user = {{ user | tojson | safe }};
window.user = JSON.parse({{ user | tojson | safe }});
{% endif %}
{% if wallet %}
window.wallet = {{ wallet | tojson | safe }};
window.wallet = JSON.parse({{ wallet | tojson | safe }});
{% endif %}
</script>
{%- endmacro %}
+190
View File
@@ -0,0 +1,190 @@
import base64
import hashlib
import json
from typing import Dict, Union
import secp256k1
from bech32 import bech32_decode, bech32_encode, convertbits
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]:
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def normalize_public_key(pubkey: str) -> str:
if pubkey.startswith("npub1"):
_, decoded_data = bech32_decode(pubkey)
assert decoded_data, "Public Key is not valid npub."
decoded_data_bits = convertbits(decoded_data, 5, 8, False)
assert decoded_data_bits, "Public Key is not valid npub."
return bytes(decoded_data_bits).hex()
assert len(pubkey) == 64, "Public key has wrong length."
try:
int(pubkey, 16)
except Exception as exc:
raise AssertionError("Public Key is not valid hex.") from exc
return pubkey
def hex_to_npub(hex_pubkey: str) -> str:
"""
Converts a hex public key to a Nostr public key.
Args:
hex_pubkey (str): The hex public key to convert.
Returns:
str: The Nostr public key.
"""
normalize_public_key(hex_pubkey)
pubkey_bytes = bytes.fromhex(hex_pubkey)
bits = convertbits(pubkey_bytes, 8, 5, True)
assert bits
return bech32_encode("npub", bits)
+14 -1
View File
@@ -95,8 +95,10 @@ class BoltzWallet(Wallet):
wallet_id=self.wallet_id,
accept_zero_conf=True,
external_pay=True,
description=memo,
)
if memo is not None:
# boltz rejects nbsp char (produced by JS Intl.NumberFormat api)
request.description = memo.replace("\xa0", " ")
response: boltzrpc_pb2.CreateReverseSwapResponse
try:
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
@@ -133,6 +135,17 @@ class BoltzWallet(Wallet):
)
response: boltzrpc_pb2.CreateSwapResponse
response = await self.rpc.CreateSwap(request, metadata=self.metadata)
# empty swap id means that the invoice included a magic routing hint and was
# paid on the liquid network directly
# docs: https://docs.boltz.exchange/api/magic-routing-hints
if response.id == "":
# note that there is no way to provide a checking id here,
# but there is no need since it immediately is considered as successfull
return PaymentResponse(
ok=True,
checking_id=response.id,
)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
+13 -11
View File
@@ -30,17 +30,19 @@ from .base import (
class FakeWallet(Wallet):
queue: asyncio.Queue = asyncio.Queue(0)
payment_secrets: Dict[str, str] = {}
paid_invoices: Set[str] = set()
secret: str = settings.fake_wallet_secret
privkey: str = hashlib.pbkdf2_hmac(
"sha256",
secret.encode(),
b"FakeWallet",
2048,
32,
).hex()
def __init__(self) -> None:
self.queue: asyncio.Queue = asyncio.Queue(0)
self.payment_secrets: Dict[str, str] = {}
self.paid_invoices: Set[str] = set()
self.secret: str = settings.fake_wallet_secret
self.privkey: str = hashlib.pbkdf2_hmac(
"sha256",
self.secret.encode(),
b"FakeWallet",
2048,
32,
).hex()
async def cleanup(self):
pass
+7 -149
View File
@@ -1,5 +1,4 @@
import asyncio
import base64
import hashlib
import json
import random
@@ -9,13 +8,17 @@ from urllib.parse import parse_qs, unquote, urlparse
import secp256k1
from bolt11 import decode as bolt11_decode
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger
from websockets.client import connect as ws_connect
from lnbits.settings import settings
from lnbits.utils.nostr import (
decrypt_content,
encrypt_content,
json_dumps,
sign_event,
verify_event,
)
from .base import (
InvoiceResponse,
@@ -806,148 +809,3 @@ def parse_nwc(nwc) -> Dict:
else:
raise ValueError("Invalid NWC pairing url")
return data
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event
+217 -97
View File
@@ -9,14 +9,15 @@
"axios": "^1.7.7",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"nostr-tools": "^2.7.2",
"qrcode.vue": "^3.4.1",
"quasar": "2.16.10",
"quasar": "2.17.0",
"showdown": "^2.1.0",
"underscore": "^1.13.7",
"vue": "3.5.2",
"vue-i18n": "^9.14.0",
"vue-qrcode-reader": "^5.5.7",
"vue-router": "4.4.3",
"vue": "3.5.8",
"vue-i18n": "^10.0.3",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
},
"devDependencies": {
@@ -71,12 +72,12 @@
}
},
"node_modules/@intlify/core-base": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.0.tgz",
"integrity": "sha512-zJn0imh9HIsZZUtt9v8T16PeVstPv6bP2YzlrYJwoF8F30gs4brZBwW2KK6EI5WYKFi3NeqX6+UU4gniz5TkGg==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.3.tgz",
"integrity": "sha512-ysJnTGDtuXPa6R2Ii4JIvfMVvDahUUny3aY8+P4r6/0TYHkblgzIMjV6cAn60em67AB0M7OWNAdcAVfWWeN8Qg==",
"dependencies": {
"@intlify/message-compiler": "9.14.0",
"@intlify/shared": "9.14.0"
"@intlify/message-compiler": "10.0.3",
"@intlify/shared": "10.0.3"
},
"engines": {
"node": ">= 16"
@@ -86,11 +87,11 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.0.tgz",
"integrity": "sha512-sXNsoMI0YsipSXW8SR75drmVK56tnJHoYbPXUv2Cf9lz6FzvwsosFm6JtC1oQZI/kU+n7qx0qRrEWkeYFTgETA==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.3.tgz",
"integrity": "sha512-KC2fG8nCzSYmXjHptEt6i/xM3k6S2szsPaHDCRgWKEYAbeHe6JFm6X4KRw3Csy112A8CxpavMi1dh3h7khwV5w==",
"dependencies": {
"@intlify/shared": "9.14.0",
"@intlify/shared": "10.0.3",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -101,9 +102,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.0.tgz",
"integrity": "sha512-r+N8KRQL7LgN1TMTs1A2svfuAU0J94Wu9wWdJVJqYsoMMLIeJxrPjazihfHpmJqfgZq0ah3Y9Q4pgWV2O90Fyg==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.3.tgz",
"integrity": "sha512-PWxrCb6fDlnoGLnXLlWu6d7o/HdWACB9TjRnpLro+9uyfqgWA9hvqg5vekcPRyraTieV5srCbTk/ldYw9V3LHw==",
"engines": {
"node": ">= 16"
},
@@ -173,6 +174,94 @@
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
]
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.11.tgz",
@@ -184,100 +273,100 @@
"integrity": "sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw=="
},
"node_modules/@vue/compiler-core": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.2.tgz",
"integrity": "sha512-1aP7FL2GkqfcskHWGg3lfWQpJnrmewKc+rNJ/hq9WNaAw4BEyJ5QbNChnqmbw+tJ409zdy1XWmUeXXMrCKJcQQ==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.8.tgz",
"integrity": "sha512-Uzlxp91EPjfbpeO5KtC0KnXPkuTfGsNDeaKQJxQN718uz+RqDYarEf7UhQJGK+ZYloD2taUbHTI2J4WrUaZQNA==",
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.2",
"@vue/shared": "3.5.8",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.2.tgz",
"integrity": "sha512-QY4DpT8ZIUyu/ZA5gErpSEDocGNEbHmpkZIC/d5jbp/rUF0iOJNigAy3HCCKc0PMMhDlrcysO3ufQ6Ab4MpEcQ==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.8.tgz",
"integrity": "sha512-GUNHWvoDSbSa5ZSHT9SnV5WkStWfzJwwTd6NMGzilOE/HM5j+9EB9zGXdtu/fCNEmctBqMs6C9SvVPpVPuk1Eg==",
"dependencies": {
"@vue/compiler-core": "3.5.2",
"@vue/shared": "3.5.2"
"@vue/compiler-core": "3.5.8",
"@vue/shared": "3.5.8"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.2.tgz",
"integrity": "sha512-vErEtybSU290LbMW+ChYllI9tNJEdTW1oU+8cZWINZyjlWeTSa9YqDl4/pZJSnozOI+HmcaC1Vz2eFKmXNSXZA==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.8.tgz",
"integrity": "sha512-taYpngQtSysrvO9GULaOSwcG5q821zCoIQBtQQSx7Uf7DxpR6CIHR90toPr9QfDD2mqHQPCSgoWBvJu0yV9zjg==",
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.5.2",
"@vue/compiler-dom": "3.5.2",
"@vue/compiler-ssr": "3.5.2",
"@vue/shared": "3.5.2",
"@vue/compiler-core": "3.5.8",
"@vue/compiler-dom": "3.5.8",
"@vue/compiler-ssr": "3.5.8",
"@vue/shared": "3.5.8",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.11",
"postcss": "^8.4.44",
"postcss": "^8.4.47",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.2.tgz",
"integrity": "sha512-vMtA4tQK/AM3UAYJsmouQzQpgG+h9TKiD5BV+Zt+ZyAMdicxzSEEFGWf/CykRnDpqj9fMfIHPhOezJVNxiXe2A==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.8.tgz",
"integrity": "sha512-W96PtryNsNG9u0ZnN5Q5j27Z/feGrFV6zy9q5tzJVyJaLiwYxvC0ek4IXClZygyhjm+XKM7WD9pdKi/wIRVC/Q==",
"dependencies": {
"@vue/compiler-dom": "3.5.2",
"@vue/shared": "3.5.2"
"@vue/compiler-dom": "3.5.8",
"@vue/shared": "3.5.8"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz",
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.2.tgz",
"integrity": "sha512-lJwWL5bNht+2vIwU/+lnGdH+FKFxzz6z8WkoIJityPLiasWU+HDUvEsC7gm3JFwbTf7Kk+Nr9kJMaPy0HXwwxQ==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.8.tgz",
"integrity": "sha512-mlgUyFHLCUZcAYkqvzYnlBRCh0t5ZQfLYit7nukn1GR96gc48Bp4B7OIcSfVSvlG1k3BPfD+p22gi1t2n9tsXg==",
"dependencies": {
"@vue/shared": "3.5.2"
"@vue/shared": "3.5.8"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.2.tgz",
"integrity": "sha512-oU+i9sJjGEMfEhlrJ7SZv7CdSIgUNyBHnWHa0SqU2RF48V3/ATajzpWq1/DkiVJ1mtx+cQFAMKs8s/3cB3YlLQ==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.8.tgz",
"integrity": "sha512-fJuPelh64agZ8vKkZgp5iCkPaEqFJsYzxLk9vSC0X3G8ppknclNDr61gDc45yBGTaN5Xqc1qZWU3/NoaBMHcjQ==",
"dependencies": {
"@vue/reactivity": "3.5.2",
"@vue/shared": "3.5.2"
"@vue/reactivity": "3.5.8",
"@vue/shared": "3.5.8"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.2.tgz",
"integrity": "sha512-2qvysn+oR0QnFKaWZxQ90iVpWAK/WPpYmODHCv24IDXjsBrdHbjLBj9s6YBdPaMuQhs0LNsmhsgZYZBkszLg6g==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.8.tgz",
"integrity": "sha512-DpAUz+PKjTZPUOB6zJgkxVI3GuYc2iWZiNeeHQUw53kdrparSTG6HeXUrYDjaam8dVsCdvQxDz6ZWxnyjccUjQ==",
"dependencies": {
"@vue/reactivity": "3.5.2",
"@vue/runtime-core": "3.5.2",
"@vue/shared": "3.5.2",
"@vue/reactivity": "3.5.8",
"@vue/runtime-core": "3.5.8",
"@vue/shared": "3.5.8",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.2.tgz",
"integrity": "sha512-3POhYCA8KfbmuDuUiNbMXnpdh9pwE4SvAqo7VvACjklLkf3AaMkY3TvV7APeEa/WQezrnL+E4X2ASpJsKeS4cQ==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.8.tgz",
"integrity": "sha512-7AmC9/mEeV9mmXNVyUIm1a1AjUhyeeGNbkLh39J00E7iPeGks8OGRB5blJiMmvqSh8SkaS7jkLWSpXtxUCeagA==",
"dependencies": {
"@vue/compiler-ssr": "3.5.2",
"@vue/shared": "3.5.2"
"@vue/compiler-ssr": "3.5.8",
"@vue/shared": "3.5.8"
},
"peerDependencies": {
"vue": "3.5.2"
"vue": "3.5.8"
}
},
"node_modules/@vue/shared": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.2.tgz",
"integrity": "sha512-Ce89WNFBzcDca/AgFTxgX4/K4iAyF7oFIp8Z5aBbFBNbtpwnQr+5pZOoHndxnjE2h+YFcipVMzs9UL11XB6dwA=="
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.8.tgz",
"integrity": "sha512-mJleSWbAGySd2RJdX1RBtcrUBX6snyOc0qHpgk3lGi4l9/P/3ny3ELqFWqYdkXIwwNN/kdm8nD9ky8o6l/Lx2A=="
},
"node_modules/acorn": {
"version": "8.11.3",
@@ -853,6 +942,36 @@
"node": ">=0.10.0"
}
},
"node_modules/nostr-tools": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz",
"integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"optional": true
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -891,9 +1010,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.45",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz",
"integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==",
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"funding": [
{
"type": "opencollective",
@@ -910,8 +1029,8 @@
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.0",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -962,9 +1081,9 @@
}
},
"node_modules/quasar": {
"version": "2.16.10",
"resolved": "https://registry.npmjs.org/quasar/-/quasar-2.16.10.tgz",
"integrity": "sha512-n4fPFabxtgUO2gsYXpg6WYkx0eTDi+CGyTmbI+HqiYQlGqAIa8qjuOIuKzX/wrkg5vKx4PvnCthTImOYHGT8Hg==",
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/quasar/-/quasar-2.17.0.tgz",
"integrity": "sha512-xFWwCt4FGuaC0M4/MA5drjBiCP7kj/5BsUPv2+dDIlyQG9YGvKIewCnWYYt02r4ijRqJSzPb7TsH89Gzkno1Mg==",
"engines": {
"node": ">= 10.18.1",
"npm": ">= 6.13.4",
@@ -1077,9 +1196,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
@@ -1162,15 +1281,15 @@
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
},
"node_modules/vue": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.2.tgz",
"integrity": "sha512-w1YB4lAwC9ByH6AnFY0JvZF+y70Usul9jDfKIKtM5xA97q/JPS5R7mqq0fhA6D2PQxYPZdgb5jzFKLyOga5pnw==",
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.8.tgz",
"integrity": "sha512-hvuvuCy51nP/1fSRvrrIqTLSvrSyz2Pq+KQ8S8SXCxTWVE0nMaOnSDnSOxV1eYmGfvK7mqiwvd1C59CEEz7dAQ==",
"dependencies": {
"@vue/compiler-dom": "3.5.2",
"@vue/compiler-sfc": "3.5.2",
"@vue/runtime-dom": "3.5.2",
"@vue/server-renderer": "3.5.2",
"@vue/shared": "3.5.2"
"@vue/compiler-dom": "3.5.8",
"@vue/compiler-sfc": "3.5.8",
"@vue/runtime-dom": "3.5.8",
"@vue/server-renderer": "3.5.8",
"@vue/shared": "3.5.8"
},
"peerDependencies": {
"typescript": "*"
@@ -1182,12 +1301,12 @@
}
},
"node_modules/vue-i18n": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.0.tgz",
"integrity": "sha512-LxmpRuCt2rI8gqU+kxeflRZMQn4D5+4M3oP3PWZdowW/ePJraHqhF7p4CuaME52mUxdw3Mmy2yAUKgfZYgCRjA==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.3.tgz",
"integrity": "sha512-8ul2S4Hy9orKs7eOlkw/zqnVu98GttUdyIMRyjoMpv6hFPxnybgBLdep/UCmdan5kUHyxqMnr2cGHTBuPBYJaw==",
"dependencies": {
"@intlify/core-base": "9.14.0",
"@intlify/shared": "9.14.0",
"@intlify/core-base": "10.0.3",
"@intlify/shared": "10.0.3",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@@ -1201,9 +1320,10 @@
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.7.tgz",
"integrity": "sha512-aflcKS6sQ5CDlmoyAaJJQ3OFeMcdnF6RRO2zHBlmKkHKL5nwPt+ayl/TCw+LvUBXhmfR3kNtPC5oJtgk9TcrBw==",
"version": "5.5.11",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.11.tgz",
"integrity": "sha512-Ec/bVML1jgxSX+usbgdcXGhOFEFo4EzApCO2CNT1YK0Dcb0Mp7ASygz78RJJs22SU2oI7vz9iJDyr4ucSDTvjQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
@@ -1213,11 +1333,11 @@
}
},
"node_modules/vue-router": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.3.tgz",
"integrity": "sha512-sv6wmNKx2j3aqJQDMxLFzs/u/mjA9Z5LCgy6BE0f7yFWMjrPLnS/sPNn8ARY/FXw6byV18EFutn5lTO6+UsV5A==",
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.5.tgz",
"integrity": "sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==",
"dependencies": {
"@vue/devtools-api": "^6.6.3"
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
+9 -7
View File
@@ -24,13 +24,14 @@
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"qrcode.vue": "^3.4.1",
"quasar": "2.16.10",
"quasar": "2.17.0",
"showdown": "^2.1.0",
"nostr-tools": "^2.7.2",
"underscore": "^1.13.7",
"vue": "3.5.2",
"vue-i18n": "^9.14.0",
"vue-qrcode-reader": "^5.5.7",
"vue-router": "4.4.3",
"vue": "3.5.8",
"vue-i18n": "^10.0.3",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
},
"vendor": [
@@ -46,7 +47,8 @@
"./node_modules/qrcode.vue/dist/qrcode.vue.browser.js",
"./node_modules/chart.js/dist/chart.umd.js",
"./node_modules/quasar/dist/quasar.css",
"./node_modules/showdown/dist/showdown.js"
"./node_modules/showdown/dist/showdown.js",
"./node_modules/nostr-tools/lib/nostr.bundle.js"
],
"bundle": {
"js": [
@@ -62,6 +64,7 @@
"vendor/qrcode.vue.browser.js",
"vendor/chart.umd.js",
"vendor/showdown.js",
"vendor/nostr.bundle.js",
"i18n/i18n.js",
"i18n/de.js",
"i18n/en.js",
@@ -89,7 +92,6 @@
"components": [
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/extension-rating.js",
"js/components/payment-list.js",
"js/components/payment-chart.js",
"js/components.js",
Generated
+8 -8
View File
@@ -997,18 +997,18 @@ test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.113.0"
version = "0.115.2"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.113.0-py3-none-any.whl", hash = "sha256:c8d364485b6361fb643d53920a18d58a696e189abcb901ec03b487e35774c476"},
{file = "fastapi-0.113.0.tar.gz", hash = "sha256:b7cf9684dc154dfc93f8b718e5850577b529889096518df44defa41e73caf50f"},
{file = "fastapi-0.115.2-py3-none-any.whl", hash = "sha256:61704c71286579cc5a598763905928f24ee98bfcc07aabe84cfefb98812bbc86"},
{file = "fastapi-0.115.2.tar.gz", hash = "sha256:3995739e0b09fa12f984bce8fa9ae197b35d433750d3d312422d846e283697ee"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.37.2,<0.39.0"
starlette = ">=0.37.2,<0.41.0"
typing-extensions = ">=4.8.0"
[package.extras]
@@ -2730,13 +2730,13 @@ uvicorn = "*"
[[package]]
name = "starlette"
version = "0.37.2"
version = "0.40.0"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
{file = "starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4"},
{file = "starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35"},
]
[package.dependencies]
@@ -3193,4 +3193,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12 | ^3.11 | ^3.10 | ^3.9"
content-hash = "f9f70d510dbe30a94f68fc12fcf1d11c6fb754367e9f36e6167be2ca19e28495"
content-hash = "f4847cf020ad21818fdad153d1845ed9c24d41d631aed1f6eec16158f6f295fb"
+3 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc2"
version = "1.0.0-rc5"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
@@ -16,7 +16,7 @@ python = "^3.12 | ^3.11 | ^3.10 | ^3.9"
bech32 = "1.2.0"
click = "8.1.7"
ecdsa = "0.19.0"
fastapi = "0.113.0"
fastapi = "0.115.2"
httpx = "0.27.0"
jinja2 = "3.1.4"
lnurl = "0.5.3"
@@ -201,6 +201,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
"validator",
]
[tool.ruff.lint.mccabe]
+3 -2
View File
@@ -1,6 +1,7 @@
import pytest
from lnbits.settings import settings
from lnbits.core.models import User
from lnbits.settings import Settings
@pytest.mark.asyncio
@@ -18,7 +19,7 @@ async def test_admin_get_settings(client, superuser):
@pytest.mark.asyncio
async def test_admin_update_settings(client, superuser):
async def test_admin_update_settings(client, superuser: User, settings: Settings):
new_site_title = "UPDATED SITETITLE"
response = await client.put(
f"/admin/api/v1/settings?usr={superuser.id}",
+15 -10
View File
@@ -5,7 +5,7 @@ import pytest
from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.views.payment_api import api_payment
from lnbits.settings import settings
from lnbits.settings import Settings
from ..helpers import (
get_random_invoice_data,
@@ -14,10 +14,13 @@ from ..helpers import (
# create account POST /api/v1/account
@pytest.mark.asyncio
async def test_create_account(client):
async def test_create_account(client, settings: Settings):
settings.lnbits_allow_new_accounts = False
response = await client.post("/api/v1/account", json={"name": "test"})
assert response.status_code == 403
assert response.status_code == 400
assert response.json().get("detail") == "Account creation is disabled."
settings.lnbits_allow_new_accounts = True
response = await client.post("/api/v1/account", json={"name": "test"})
assert response.status_code == 200
@@ -475,7 +478,7 @@ async def test_update_wallet(client, adminkey_headers_from):
@pytest.mark.asyncio
async def test_fiat_tracking(client, adminkey_headers_from):
async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
async def create_invoice():
data = await get_random_invoice_data()
response = await client.post(
@@ -501,13 +504,15 @@ async def test_fiat_tracking(client, adminkey_headers_from):
settings.lnbits_default_accounting_currency = "USD"
payment = await create_invoice()
assert payment["extra"]["wallet_fiat_currency"] == "USD"
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
assert payment["extra"]["wallet_fiat_rate"]
extra = payment["extra"]
assert extra["wallet_fiat_currency"] == "USD"
assert extra["wallet_fiat_amount"] != payment["amount"]
assert extra["wallet_fiat_rate"]
await update_currency("EUR")
payment = await create_invoice()
assert payment["extra"]["wallet_fiat_currency"] == "EUR"
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
assert payment["extra"]["wallet_fiat_rate"]
extra = payment["extra"]
assert extra["wallet_fiat_currency"] == "EUR"
assert extra["wallet_fiat_amount"] != payment["amount"]
assert extra["wallet_fiat_rate"]
File diff suppressed because it is too large Load Diff
+102 -30
View File
@@ -1,39 +1,59 @@
# ruff: noqa: E402
import asyncio
from time import time
import uvloop
from asgi_lifespan import LifespanManager
uvloop.install()
from lnbits.wallets.fake import FakeWallet
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
from time import time
from uuid import uuid4
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from fastapi.testclient import TestClient
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient
from lnbits.app import create_app
from lnbits.core.crud import (
create_account,
create_wallet,
get_user,
update_payment_status,
delete_account,
get_account,
get_account_by_username,
get_payment,
get_user_from_account,
update_payment,
)
from lnbits.core.models import CreateInvoice, PaymentState
from lnbits.core.services import update_wallet_balance
from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import settings
from lnbits.settings import AuthMethods, Settings
from lnbits.settings import settings as lnbits_settings
from tests.helpers import (
get_random_invoice_data,
)
# override settings for tests
settings.lnbits_admin_extensions = []
settings.lnbits_data_folder = "./tests/data"
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
@pytest_asyncio.fixture(scope="session")
def settings():
# override settings for tests
lnbits_settings.lnbits_admin_extensions = []
lnbits_settings.lnbits_data_folder = "./tests/data"
lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = True
yield lnbits_settings
@pytest.fixture(autouse=True)
def run_before_and_after_tests(settings: Settings):
"""Fixture to execute asserts before and after a test is run"""
_settings_cleanup(settings)
yield # this is where the testing happens
_settings_cleanup(settings)
@pytest_asyncio.fixture(scope="session")
@@ -45,7 +65,7 @@ def event_loop():
# use session scope to run once before and once after all tests
@pytest_asyncio.fixture(scope="session")
async def app():
async def app(settings: Settings):
app = create_app()
async with LifespanManager(app) as manager:
settings.first_install = False
@@ -53,9 +73,18 @@ async def app():
@pytest_asyncio.fixture(scope="session")
async def client(app):
async def client(app, settings: Settings):
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(app=app, base_url=url) as client:
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
# function scope
@pytest_asyncio.fixture(scope="function")
async def http_client(app, settings: Settings):
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
@@ -69,15 +98,33 @@ async def db():
yield Database("database")
@pytest_asyncio.fixture(scope="session")
async def user_alan():
account = await get_account_by_username("alan")
if account:
await delete_account(account.id)
account = Account(
id=uuid4().hex,
email="alan@lnbits.com",
username="alan",
)
account.hash_password("secret1234")
user = await create_user_account(account)
yield user
@pytest_asyncio.fixture(scope="session")
async def from_user():
user = await create_account()
user = await create_user_account()
yield user
@pytest_asyncio.fixture(scope="session")
async def from_wallet(from_user):
user = from_user
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
await update_wallet_balance(
wallet_id=wallet.id,
@@ -96,12 +143,12 @@ async def from_wallet_ws(from_wallet, test_client):
@pytest_asyncio.fixture(scope="session")
async def to_user():
user = await create_account()
user = await create_user_account()
yield user
@pytest.fixture()
def from_super_user(from_user):
def from_super_user(from_user: User, settings: Settings):
prev = settings.super_user
settings.super_user = from_user.id
yield from_user
@@ -109,8 +156,10 @@ def from_super_user(from_user):
@pytest_asyncio.fixture(scope="session")
async def superuser():
user = await get_user(settings.super_user)
async def superuser(settings: Settings):
account = await get_account(settings.super_user)
assert account, "Superuser not found"
user = await get_user_from_account(account)
yield user
@@ -170,12 +219,21 @@ async def adminkey_headers_to(to_wallet):
@pytest_asyncio.fixture(scope="session")
async def invoice(to_wallet):
async def invoice(client, adminkey_headers_from):
data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data)
invoice = await api_payments_create_invoice(invoice_data, to_wallet)
yield invoice
del invoice
response = await client.post(
"/api/v1/payments", headers=adminkey_headers_from, json=invoice_data.dict()
)
assert response.is_success
data = response.json()
assert data["checking_id"]
yield data["bolt11"]
@pytest_asyncio.fixture(scope="function")
async def external_funding_source():
yield FakeWallet()
@pytest_asyncio.fixture(scope="session")
@@ -199,7 +257,21 @@ async def fake_payments(client, adminkey_headers_from):
assert response.is_success
data = response.json()
assert data["checking_id"]
await update_payment_status(data["checking_id"], status=PaymentState.SUCCESS)
payment = await get_payment(data["checking_id"])
payment.status = PaymentState.SUCCESS
await update_payment(payment)
params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params
def _settings_cleanup(settings: Settings):
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 2000
settings.lnbits_service_fee = 0
settings.lnbits_wallet_limit_daily_max_withdraw = 0
settings.lnbits_admin_extensions = []

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