Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c9d294cd | ||
|
|
4e342a7ab2 | ||
|
|
d7e180d855 | ||
|
|
af863b8c8f | ||
|
|
d8d898b20b |
+3
-3
@@ -9,8 +9,8 @@
|
|||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
# 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
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
# only through the Admin UI.
|
# only through the Admin UI.
|
||||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
# Disable this to make LNbits use this config file again.
|
||||||
LNBITS_ADMIN_UI=true
|
LNBITS_ADMIN_UI=false
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
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.
|
# 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_SECRET_KEY=""
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
|
||||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
# Set this flag if HTTP is used for OAuth
|
# Set this flag if HTTP is used for OAuth
|
||||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||||
|
|||||||
@@ -46,10 +46,7 @@ runs:
|
|||||||
|
|
||||||
- name: Install the project dependencies
|
- name: Install the project dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: poetry install
|
||||||
poetry install
|
|
||||||
# needed for conv tests
|
|
||||||
poetry add psycopg2-binary
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ inputs.node-version }}
|
- name: Use Node.js ${{ inputs.node-version }}
|
||||||
if: ${{ (inputs.npm == 'true') }}
|
if: ${{ (inputs.npm == 'true') }}
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
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:
|
docker:
|
||||||
|
needs: [ release ]
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.ref_name }}
|
tag: ${{ github.ref_name }}
|
||||||
|
|||||||
+2
-6
@@ -35,7 +35,6 @@ __bundle__
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
node_modules
|
node_modules
|
||||||
lnbits/static/bundle.js
|
lnbits/static/bundle.js
|
||||||
lnbits/static/bundle-components.js
|
|
||||||
lnbits/static/bundle.css
|
lnbits/static/bundle.css
|
||||||
lnbits/static/bundle.min.js.old
|
lnbits/static/bundle.min.js.old
|
||||||
lnbits/static/bundle.min.css.old
|
lnbits/static/bundle.min.css.old
|
||||||
@@ -50,11 +49,8 @@ fly.toml
|
|||||||
lnbits-backup.zip
|
lnbits-backup.zip
|
||||||
|
|
||||||
# Ignore extensions (post installable extension PR)
|
# Ignore extensions (post installable extension PR)
|
||||||
/lnbits/extensions
|
extensions
|
||||||
/upgrades/
|
upgrades/
|
||||||
|
|
||||||
# builded python package
|
# builded python package
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# jetbrains
|
|
||||||
.idea
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
**/lnbits/static/vendor
|
**/lnbits/static/vendor
|
||||||
**/lnbits/static/bundle.*
|
**/lnbits/static/bundle.*
|
||||||
**/lnbits/static/bundle-components.*
|
|
||||||
**/lnbits/static/css/*
|
**/lnbits/static/css/*
|
||||||
|
|
||||||
flake.lock
|
flake.lock
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ checkeditorconfig:
|
|||||||
dev:
|
dev:
|
||||||
poetry run lnbits --reload
|
poetry run lnbits --reload
|
||||||
|
|
||||||
docker:
|
|
||||||
docker build -t lnbits/lnbits .
|
|
||||||
|
|
||||||
test-wallets:
|
test-wallets:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
@@ -87,7 +84,6 @@ migration:
|
|||||||
poetry run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
LNBITS_ADMIN_UI=False \
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -107,21 +103,24 @@ sass:
|
|||||||
|
|
||||||
bundle:
|
bundle:
|
||||||
npm install
|
npm install
|
||||||
npm run bundle
|
npm run sass
|
||||||
|
npm run vendor_copy
|
||||||
|
npm run vendor_json
|
||||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||||
|
npm run vendor_bundle_css
|
||||||
|
npm run vendor_minify_css
|
||||||
|
npm run vendor_bundle_js
|
||||||
|
npm run vendor_minify_js
|
||||||
|
|
||||||
checkbundle:
|
checkbundle:
|
||||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||||
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||||
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
|
|
||||||
make bundle
|
make bundle
|
||||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||||
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||||
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
|
|
||||||
@echo "Bundle is OK"
|
@echo "Bundle is OK"
|
||||||
rm lnbits/static/bundle.min.js.old
|
rm lnbits/static/bundle.min.js.old
|
||||||
rm lnbits/static/bundle.min.css.old
|
rm lnbits/static/bundle.min.css.old
|
||||||
rm lnbits/static/bundle-components.min.js.old
|
|
||||||
|
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ chmod +x lnbits.sh &&
|
|||||||
|
|
||||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||||
|
|
||||||
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||||
|
|
||||||
## Option 3: Nix
|
## Option 3: Nix
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
apt-get install apache2 certbot
|
apt-get install apache2 certbot
|
||||||
a2enmod headers ssl proxy proxy_http
|
a2enmod headers ssl proxy proxy-http
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a SSL certificate with LetsEncrypt:
|
Create a SSL certificate with LetsEncrypt:
|
||||||
@@ -414,7 +414,7 @@ EOF
|
|||||||
Restart Apache2:
|
Restart Apache2:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
service apache2 restart
|
service restart apache2
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running behind an Nginx reverse proxy over HTTPS
|
## Running behind an Nginx reverse proxy over HTTPS
|
||||||
@@ -468,7 +468,7 @@ EOF
|
|||||||
Restart nginx:
|
Restart nginx:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
service nginx restart
|
service restart nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using https without reverse proxy
|
## Using https without reverse proxy
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
meta.rev = self.dirtyRev or self.rev;
|
meta.rev = self.dirtyRev or self.rev;
|
||||||
meta.mainProgram = projectName;
|
meta.mainProgram = projectName;
|
||||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||||
coincurve = prev.coincurve.override { preferWheel = true; };
|
|
||||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||||
ruff = prev.ruff.override { preferWheel = true; };
|
ruff = prev.ruff.override { preferWheel = true; };
|
||||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ elif [ ! -d lnbits/wallets ]; then
|
|||||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set path for running after install
|
|
||||||
export PATH="/home/$USER/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# Install the dependencies using Poetry
|
# Install the dependencies using Poetry
|
||||||
poetry env use python3.9
|
poetry env use python3.9
|
||||||
poetry install --only main
|
poetry install --only main
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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",
|
|
||||||
]
|
|
||||||
|
|||||||
+25
-23
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -17,11 +17,10 @@ from slowapi.util import get_remote_address
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
get_db_version,
|
get_dbversions,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
update_installed_extension_state,
|
update_installed_extension_state,
|
||||||
)
|
)
|
||||||
from lnbits.core.extensions.helpers import version_parse
|
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
from lnbits.core.helpers import migrate_extension_database
|
||||||
from lnbits.core.tasks import ( # watchdog_task
|
from lnbits.core.tasks import ( # watchdog_task
|
||||||
killswitch_task,
|
killswitch_task,
|
||||||
@@ -45,8 +44,14 @@ from lnbits.wallets import get_funding_source, set_funding_source
|
|||||||
from .commands import migrate_databases
|
from .commands import migrate_databases
|
||||||
from .core import init_core_routers
|
from .core import init_core_routers
|
||||||
from .core.db import core_app_extra
|
from .core.db import core_app_extra
|
||||||
from .core.extensions.models import Extension, ExtensionMeta, InstallableExtension
|
|
||||||
from .core.services import check_admin_settings, check_webpush_settings
|
from .core.services import check_admin_settings, check_webpush_settings
|
||||||
|
from .core.views.extension_api import add_installed_extension
|
||||||
|
from .extension_manager import (
|
||||||
|
Extension,
|
||||||
|
InstallableExtension,
|
||||||
|
get_valid_extensions,
|
||||||
|
version_parse,
|
||||||
|
)
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
CustomGZipMiddleware,
|
CustomGZipMiddleware,
|
||||||
ExtensionsRedirectMiddleware,
|
ExtensionsRedirectMiddleware,
|
||||||
@@ -238,8 +243,6 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
settings.deactivate_extension_paths(ext.id)
|
|
||||||
await update_installed_extension_state(ext_id=ext.id, active=False)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
||||||
)
|
)
|
||||||
@@ -251,7 +254,7 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
|
|
||||||
async def build_all_installed_extensions_list(
|
async def build_all_installed_extensions_list(
|
||||||
include_deactivated: Optional[bool] = True,
|
include_deactivated: Optional[bool] = True,
|
||||||
) -> list[InstallableExtension]:
|
) -> List[InstallableExtension]:
|
||||||
"""
|
"""
|
||||||
Returns a list of all the installed extensions plus the extensions that
|
Returns a list of all the installed extensions plus the extensions that
|
||||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||||
@@ -271,13 +274,8 @@ async def build_all_installed_extensions_list(
|
|||||||
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
||||||
|
|
||||||
if release:
|
if release:
|
||||||
ext_meta = ExtensionMeta(installed_release=release)
|
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
id=ext_id,
|
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
||||||
name=ext_id,
|
|
||||||
version=release.version,
|
|
||||||
icon=release.icon,
|
|
||||||
meta=ext_meta,
|
|
||||||
)
|
)
|
||||||
installed_extensions.append(ext_info)
|
installed_extensions.append(ext_info)
|
||||||
|
|
||||||
@@ -308,16 +306,18 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
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)
|
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext)
|
extension = Extension.from_installable_ext(ext)
|
||||||
register_ext_routes(app, extension)
|
register_ext_routes(app, extension)
|
||||||
|
|
||||||
current_version = await get_db_version(ext.id)
|
current_version = (await get_dbversions()).get(ext.id, 0)
|
||||||
await migrate_extension_database(ext, current_version)
|
await migrate_extension_database(extension, current_version)
|
||||||
|
|
||||||
# mount routes for the new version
|
# mount routes for the new version
|
||||||
core_app_extra.register_new_ext_routes(extension)
|
core_app_extra.register_new_ext_routes(extension)
|
||||||
|
ext.notify_upgrade(extension.upgrade_hash)
|
||||||
|
|
||||||
|
|
||||||
def register_custom_extensions_path():
|
def register_custom_extensions_path():
|
||||||
@@ -380,22 +380,24 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
)
|
)
|
||||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||||
|
|
||||||
ext_redirects = (
|
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||||
getattr(ext_module, f"{ext.code}_redirect_paths")
|
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||||
if hasattr(ext_module, f"{ext.code}_redirect_paths")
|
settings.lnbits_extensions_redirects = [
|
||||||
else []
|
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
|
||||||
)
|
]
|
||||||
|
for r in ext_redirects:
|
||||||
|
r["ext_id"] = ext.code
|
||||||
|
settings.lnbits_extensions_redirects.append(r)
|
||||||
|
|
||||||
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
|
logger.trace(f"adding route for extension {ext_module}")
|
||||||
|
|
||||||
logger.trace(f"Adding route for extension {ext_module}.")
|
|
||||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
||||||
app.include_router(router=ext_route, prefix=prefix)
|
app.include_router(router=ext_route, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
async def check_and_register_extensions(app: FastAPI):
|
async def check_and_register_extensions(app: FastAPI):
|
||||||
await check_installed_extensions(app)
|
await check_installed_extensions(app)
|
||||||
for ext in Extension.get_valid_extensions(False):
|
for ext in get_valid_extensions(False):
|
||||||
try:
|
try:
|
||||||
register_ext_routes(app, ext)
|
register_ext_routes(app, ext)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
+35
-30
@@ -3,7 +3,8 @@ import importlib
|
|||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import List, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
@@ -17,26 +18,25 @@ from lnbits.core.crud import (
|
|||||||
delete_unused_wallets,
|
delete_unused_wallets,
|
||||||
delete_wallet_by_id,
|
delete_wallet_by_id,
|
||||||
delete_wallet_payment,
|
delete_wallet_payment,
|
||||||
get_db_versions,
|
get_dbversions,
|
||||||
get_installed_extension,
|
get_installed_extension,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_payment,
|
|
||||||
get_payments,
|
get_payments,
|
||||||
remove_deleted_wallets,
|
remove_deleted_wallets,
|
||||||
update_payment,
|
update_payment_status,
|
||||||
)
|
)
|
||||||
from lnbits.core.extensions.models import (
|
from lnbits.core.helpers import migrate_databases
|
||||||
CreateExtension,
|
from lnbits.core.models import Payment, PaymentState, User
|
||||||
ExtensionRelease,
|
|
||||||
InstallableExtension,
|
|
||||||
)
|
|
||||||
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.services import check_admin_settings
|
||||||
from lnbits.core.views.extension_api import (
|
from lnbits.core.views.extension_api import (
|
||||||
api_install_extension,
|
api_install_extension,
|
||||||
api_uninstall_extension,
|
api_uninstall_extension,
|
||||||
)
|
)
|
||||||
|
from lnbits.extension_manager import (
|
||||||
|
CreateExtension,
|
||||||
|
ExtensionRelease,
|
||||||
|
InstallableExtension,
|
||||||
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets.base import Wallet
|
from lnbits.wallets.base import Wallet
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ def database_migrate():
|
|||||||
async def db_versions():
|
async def db_versions():
|
||||||
"""Show current database versions"""
|
"""Show current database versions"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
click.echo(await get_db_versions(conn))
|
click.echo(await get_dbversions(conn))
|
||||||
|
|
||||||
|
|
||||||
@db.command("cleanup-wallets")
|
@db.command("cleanup-wallets")
|
||||||
@@ -173,10 +173,9 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
|||||||
async def database_revert_payment(checking_id: str):
|
async def database_revert_payment(checking_id: str):
|
||||||
"""Mark payment as pending"""
|
"""Mark payment as pending"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
payment = await get_payment(checking_id=checking_id, conn=conn)
|
await update_payment_status(
|
||||||
payment.status = PaymentState.PENDING
|
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
|
||||||
await update_payment(payment, conn=conn)
|
)
|
||||||
click.echo(f"Payment '{checking_id}' marked as pending.")
|
|
||||||
|
|
||||||
|
|
||||||
@db.command("cleanup-accounts")
|
@db.command("cleanup-accounts")
|
||||||
@@ -233,7 +232,7 @@ async def check_invalid_payments(
|
|||||||
click.echo("Funding source: " + str(funding_source))
|
click.echo("Funding source: " + str(funding_source))
|
||||||
|
|
||||||
# payments that are settled in the DB, but not at the Funding source level
|
# payments that are settled in the DB, but not at the Funding source level
|
||||||
invalid_payments: list[Payment] = []
|
invalid_payments: List[Payment] = []
|
||||||
invalid_wallets = {}
|
invalid_wallets = {}
|
||||||
for db_payment in settled_db_payments:
|
for db_payment in settled_db_payments:
|
||||||
if verbose:
|
if verbose:
|
||||||
@@ -279,10 +278,8 @@ async def extensions_list():
|
|||||||
from lnbits.app import build_all_installed_extensions_list
|
from lnbits.app import build_all_installed_extensions_list
|
||||||
|
|
||||||
for ext in await build_all_installed_extensions_list():
|
for ext in await build_all_installed_extensions_list():
|
||||||
assert (
|
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
|
||||||
ext.meta and ext.meta.installed_release
|
click.echo(f" - {ext.id} ({ext.installed_release.version})")
|
||||||
), f"Extension {ext.id} has no installed_release"
|
|
||||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
|
||||||
|
|
||||||
|
|
||||||
@extensions.command("update")
|
@extensions.command("update")
|
||||||
@@ -331,7 +328,7 @@ async def extensions_update(
|
|||||||
if extension and all_extensions:
|
if extension and all_extensions:
|
||||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||||
return
|
return
|
||||||
if url and not is_valid_url(url):
|
if url and not _is_url(url):
|
||||||
click.echo(f"Invalid '--url' option value: {url}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -405,7 +402,7 @@ async def extensions_install(
|
|||||||
):
|
):
|
||||||
"""Install a extension"""
|
"""Install a extension"""
|
||||||
click.echo(f"Installing {extension}... {repo_index}")
|
click.echo(f"Installing {extension}... {repo_index}")
|
||||||
if url and not is_valid_url(url):
|
if url and not _is_url(url):
|
||||||
click.echo(f"Invalid '--url' option value: {url}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -433,7 +430,7 @@ async def extensions_uninstall(
|
|||||||
"""Uninstall a extension"""
|
"""Uninstall a extension"""
|
||||||
click.echo(f"Uninstalling '{extension}'...")
|
click.echo(f"Uninstalling '{extension}'...")
|
||||||
|
|
||||||
if url and not is_valid_url(url):
|
if url and not _is_url(url):
|
||||||
click.echo(f"Invalid '--url' option value: {url}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -465,7 +462,7 @@ async def install_extension(
|
|||||||
source_repo: Optional[str] = None,
|
source_repo: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
if not release:
|
if not release:
|
||||||
@@ -494,7 +491,7 @@ async def update_extension(
|
|||||||
source_repo: Optional[str] = None,
|
source_repo: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
click.echo(f"Updating '{extension}' extension.")
|
click.echo(f"Updating '{extension}' extension.")
|
||||||
installed_ext = await get_installed_extension(extension)
|
installed_ext = await get_installed_extension(extension)
|
||||||
@@ -507,7 +504,7 @@ async def update_extension(
|
|||||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
installed_ext.meta and installed_ext.meta.installed_release
|
installed_ext.installed_release
|
||||||
), "Cannot find previously installed release. Please uninstall first."
|
), "Cannot find previously installed release. Please uninstall first."
|
||||||
|
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
@@ -515,7 +512,7 @@ async def update_extension(
|
|||||||
return False, "No release selected."
|
return False, "No release selected."
|
||||||
if (
|
if (
|
||||||
release.version == installed_ext.installed_version
|
release.version == installed_ext.installed_version
|
||||||
and release.source_repo == installed_ext.meta.installed_release.source_repo
|
and release.source_repo == installed_ext.installed_release.source_repo
|
||||||
):
|
):
|
||||||
click.echo(f"Extension '{extension}' already up to date.")
|
click.echo(f"Extension '{extension}' already up to date.")
|
||||||
return False, "Already up to date"
|
return False, "Already up to date"
|
||||||
@@ -614,7 +611,7 @@ async def _call_install_extension(
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
else:
|
||||||
await api_install_extension(data)
|
await api_install_extension(data, User(id="mock_id"))
|
||||||
|
|
||||||
|
|
||||||
async def _call_uninstall_extension(
|
async def _call_uninstall_extension(
|
||||||
@@ -628,7 +625,7 @@ async def _call_uninstall_extension(
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
else:
|
||||||
await api_uninstall_extension(extension)
|
await api_uninstall_extension(extension, User(id="mock_id"))
|
||||||
|
|
||||||
|
|
||||||
async def _can_run_operation(url) -> bool:
|
async def _can_run_operation(url) -> bool:
|
||||||
@@ -662,3 +659,11 @@ async def _is_lnbits_started(url: Optional[str]):
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_url(url):
|
||||||
|
try:
|
||||||
|
result = urlparse(url)
|
||||||
|
return all([result.scheme, result.netloc])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|||||||
+649
-372
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core import core_app_extra
|
|
||||||
from lnbits.core.crud import (
|
|
||||||
create_installed_extension,
|
|
||||||
delete_installed_extension,
|
|
||||||
get_db_version,
|
|
||||||
get_installed_extension,
|
|
||||||
update_installed_extension_state,
|
|
||||||
)
|
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
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_id)
|
|
||||||
if installed_ext:
|
|
||||||
ext_info.meta = installed_ext.meta
|
|
||||||
|
|
||||||
await ext_info.download_archive()
|
|
||||||
|
|
||||||
ext_info.extract_archive()
|
|
||||||
|
|
||||||
db_version = await get_db_version(ext_id)
|
|
||||||
await migrate_extension_database(ext_info, db_version)
|
|
||||||
|
|
||||||
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_id)
|
|
||||||
|
|
||||||
return extension
|
|
||||||
|
|
||||||
|
|
||||||
async def uninstall_extension(ext_id: str):
|
|
||||||
await stop_extension_background_work(ext_id)
|
|
||||||
|
|
||||||
settings.deactivate_extension_paths(ext_id)
|
|
||||||
|
|
||||||
extension = await get_installed_extension(ext_id)
|
|
||||||
if extension:
|
|
||||||
extension.clean_extension_files()
|
|
||||||
await delete_installed_extension(ext_id=ext_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def activate_extension(ext: Extension):
|
|
||||||
core_app_extra.register_new_ext_routes(ext)
|
|
||||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def deactivate_extension(ext_id: str):
|
|
||||||
settings.deactivate_extension_paths(ext_id)
|
|
||||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_extension_background_work(ext_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
|
||||||
Extensions SHOULD expose a `api_stop()` function.
|
|
||||||
"""
|
|
||||||
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
|
|
||||||
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
|
||||||
old_module = importlib.import_module(ext.module_name)
|
|
||||||
|
|
||||||
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
|
||||||
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
|
||||||
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
|
||||||
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
|
||||||
assert stop_fn_name, f"No stop function found for '{ext.module_name}'."
|
|
||||||
|
|
||||||
stop_fn = getattr(old_module, stop_fn_name)
|
|
||||||
if stop_fn:
|
|
||||||
if asyncio.iscoroutinefunction(stop_fn):
|
|
||||||
await stop_fn()
|
|
||||||
else:
|
|
||||||
stop_fn()
|
|
||||||
|
|
||||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
|
||||||
logger.warning(ex)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib import request
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from loguru import logger
|
|
||||||
from packaging import version
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
def version_parse(v: str):
|
|
||||||
"""
|
|
||||||
Wrapper for version.parse() that does not throw if the version is invalid.
|
|
||||||
Instead it return the lowest possible version ("0.0.0")
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return version.parse(v)
|
|
||||||
except Exception:
|
|
||||||
return version.parse("0.0.0")
|
|
||||||
|
|
||||||
|
|
||||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
|
||||||
if settings.lnbits_ext_github_token:
|
|
||||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
|
||||||
with request.urlopen(url, timeout=60) as dl_file:
|
|
||||||
with open(save_path, "wb") as out_file:
|
|
||||||
out_file.write(dl_file.read())
|
|
||||||
|
|
||||||
|
|
||||||
def file_hash(filename):
|
|
||||||
h = hashlib.sha256()
|
|
||||||
b = bytearray(128 * 1024)
|
|
||||||
mv = memoryview(b)
|
|
||||||
with open(filename, "rb", buffering=0) as f:
|
|
||||||
while n := f.readinto(mv):
|
|
||||||
h.update(mv[:n])
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
|
||||||
if not path:
|
|
||||||
return ""
|
|
||||||
_, _, *rest = path.split("/")
|
|
||||||
tail = "/".join(rest)
|
|
||||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
|
||||||
+81
-39
@@ -1,51 +1,49 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core import migrations as core_migrations
|
from lnbits.core import migrations as core_migrations
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
get_db_versions,
|
get_dbversions,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
update_migration_version,
|
update_migration_version,
|
||||||
)
|
)
|
||||||
from lnbits.core.db import db as core_db
|
from lnbits.core.db import db as core_db
|
||||||
from lnbits.core.extensions.models import InstallableExtension
|
|
||||||
from lnbits.core.models import DbVersion
|
|
||||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||||
|
from lnbits.extension_manager import (
|
||||||
|
Extension,
|
||||||
|
get_valid_extensions,
|
||||||
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
async def migrate_extension_database(
|
async def migrate_extension_database(ext: Extension, current_version):
|
||||||
ext: InstallableExtension, current_version: Optional[DbVersion] = None
|
|
||||||
):
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||||
ext_db = importlib.import_module(ext.module_name).db
|
ext_db = importlib.import_module(ext.module_name).db
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
logger.error(exc)
|
logger.error(exc)
|
||||||
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
|
raise ImportError(
|
||||||
|
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||||
|
) from exc
|
||||||
|
|
||||||
async with ext_db.connect() as ext_conn:
|
async with ext_db.connect() as ext_conn:
|
||||||
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
|
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
|
||||||
|
|
||||||
|
|
||||||
async def run_migration(
|
async def run_migration(
|
||||||
db: Connection,
|
db: Connection, migrations_module: Any, db_name: str, current_version: int
|
||||||
migrations_module: Any,
|
|
||||||
db_name: str,
|
|
||||||
current_version: Optional[DbVersion] = None,
|
|
||||||
):
|
):
|
||||||
matcher = re.compile(r"^m(\d\d\d)_")
|
matcher = re.compile(r"^m(\d\d\d)_")
|
||||||
for key, migrate in migrations_module.__dict__.items():
|
for key, migrate in migrations_module.__dict__.items():
|
||||||
match = matcher.match(key)
|
match = matcher.match(key)
|
||||||
if match:
|
if match:
|
||||||
version = int(match.group(1))
|
version = int(match.group(1))
|
||||||
if not current_version or version > current_version.version:
|
if version > current_version:
|
||||||
logger.debug(f"running migration {db_name}.{version}")
|
logger.debug(f"running migration {db_name}.{version}")
|
||||||
print(f"running migration {db_name}.{version}")
|
print(f"running migration {db_name}.{version}")
|
||||||
await migrate(db)
|
await migrate(db)
|
||||||
@@ -57,6 +55,68 @@ async def run_migration(
|
|||||||
await update_migration_version(conn, db_name, version)
|
await update_migration_version(conn, db_name, version)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_extension_background_work(
|
||||||
|
ext_id: str, user: str, access_token: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||||
|
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
|
||||||
|
at the root level of their API.
|
||||||
|
"""
|
||||||
|
stopped = await _stop_extension_background_work(ext_id)
|
||||||
|
|
||||||
|
if not stopped:
|
||||||
|
# fallback to REST API call
|
||||||
|
await _stop_extension_background_work_via_api(ext_id, user, access_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_extension_background_work(ext_id) -> bool:
|
||||||
|
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
|
||||||
|
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||||
|
old_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
|
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
||||||
|
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
||||||
|
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
||||||
|
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
||||||
|
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
|
||||||
|
|
||||||
|
stop_fn = getattr(old_module, stop_fn_name)
|
||||||
|
if stop_fn:
|
||||||
|
await stop_fn()
|
||||||
|
|
||||||
|
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||||
|
logger.warning(ex)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
|
||||||
|
logger.info(
|
||||||
|
f"Stopping background work for extension '{ext_id}' using the REST API."
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||||
|
headers = (
|
||||||
|
{"Authorization": "Bearer " + access_token} if access_token else None
|
||||||
|
)
|
||||||
|
resp = await client.delete(url=url, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info(f"Stopped background work for extension '{ext_id}'.")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to stop background work for '{ext_id}' using the REST API."
|
||||||
|
)
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
def to_valid_user_id(user_id: str) -> UUID:
|
def to_valid_user_id(user_id: str) -> UUID:
|
||||||
if len(user_id) < 32:
|
if len(user_id) < 32:
|
||||||
raise ValueError("User ID must have at least 128 bits")
|
raise ValueError("User ID must have at least 128 bits")
|
||||||
@@ -92,38 +152,20 @@ async def migrate_databases():
|
|||||||
if not exists:
|
if not exists:
|
||||||
await core_migrations.m000_create_migrations_table(conn)
|
await core_migrations.m000_create_migrations_table(conn)
|
||||||
|
|
||||||
current_versions = await get_db_versions(conn)
|
current_versions = await get_dbversions(conn)
|
||||||
core_version = next(
|
core_version = current_versions.get("core", 0)
|
||||||
(v for v in current_versions if v.db == "core"),
|
|
||||||
DbVersion(db="core", version=0),
|
|
||||||
)
|
|
||||||
await run_migration(conn, core_migrations, "core", core_version)
|
await run_migration(conn, core_migrations, "core", core_version)
|
||||||
|
|
||||||
# here is the first place we can be sure that the
|
# here is the first place we can be sure that the
|
||||||
# `installed_extensions` table has been created
|
# `installed_extensions` table has been created
|
||||||
await load_disabled_extension_list()
|
await load_disabled_extension_list()
|
||||||
|
|
||||||
for ext in await get_installed_extensions():
|
# todo: revisit, use installed extensions
|
||||||
current_version = next(
|
for ext in get_valid_extensions(False):
|
||||||
(v for v in current_versions if v.db == ext.id),
|
current_version = current_versions.get(ext.code, 0)
|
||||||
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:
|
try:
|
||||||
await migrate_extension_database(ext, current_version)
|
await migrate_extension_database(ext, current_version)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error migrating extension {ext.id}: {e}")
|
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||||
|
|
||||||
logger.info("✔️ All migrations done.")
|
logger.info("✔️ All migrations done.")
|
||||||
|
|
||||||
|
|
||||||
def is_valid_url(url):
|
|
||||||
try:
|
|
||||||
result = urlparse(url)
|
|
||||||
return all([result.scheme, result.netloc])
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|||||||
+58
-129
@@ -1,4 +1,4 @@
|
|||||||
import json
|
import datetime
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -100,8 +100,9 @@ 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 bolt11 TEXT")
|
||||||
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
result = await db.execute("SELECT * FROM apipayments")
|
import json
|
||||||
rows = result.mappings().all()
|
|
||||||
|
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not row["memo"] or not row["memo"].startswith("#"):
|
if not row["memo"] or not row["memo"].startswith("#"):
|
||||||
continue
|
continue
|
||||||
@@ -112,15 +113,15 @@ async def m002_add_fields_to_apipayments(db):
|
|||||||
new = row["memo"][len(prefix) :]
|
new = row["memo"][len(prefix) :]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE apipayments SET extra = :extra, memo = :memo1
|
UPDATE apipayments SET extra = ?, memo = ?
|
||||||
WHERE checking_id = :checking_id AND memo = :memo2
|
WHERE checking_id = ? AND memo = ?
|
||||||
""",
|
""",
|
||||||
{
|
(
|
||||||
"extra": json.dumps({"tag": ext}),
|
json.dumps({"tag": ext}),
|
||||||
"memo1": new,
|
new,
|
||||||
"checking_id": row["checking_id"],
|
row["checking_id"],
|
||||||
"memo2": row["memo"],
|
row["memo"],
|
||||||
},
|
),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
@@ -211,18 +212,19 @@ async def m007_set_invoice_expiries(db):
|
|||||||
Precomputes invoice expiry for existing pending incoming payments.
|
Precomputes invoice expiry for existing pending incoming payments.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
rows = await (
|
||||||
f"""
|
await db.execute(
|
||||||
SELECT bolt11, checking_id
|
f"""
|
||||||
FROM apipayments
|
SELECT bolt11, checking_id
|
||||||
WHERE pending = true
|
FROM apipayments
|
||||||
AND amount > 0
|
WHERE pending = true
|
||||||
AND bolt11 IS NOT NULL
|
AND amount > 0
|
||||||
AND expiry IS NULL
|
AND bolt11 IS NOT NULL
|
||||||
AND time < {db.timestamp_now}
|
AND expiry IS NULL
|
||||||
"""
|
AND time < {db.timestamp_now}
|
||||||
)
|
"""
|
||||||
rows = result.mappings().all()
|
)
|
||||||
|
).fetchall()
|
||||||
if len(rows):
|
if len(rows):
|
||||||
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
||||||
for i, (
|
for i, (
|
||||||
@@ -234,17 +236,22 @@ async def m007_set_invoice_expiries(db):
|
|||||||
if invoice.expiry is None:
|
if invoice.expiry is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expiration_date = invoice.date + invoice.expiry
|
expiration_date = datetime.datetime.fromtimestamp(
|
||||||
|
invoice.date + invoice.expiry
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||||
f" {invoice.payment_hash} to {expiration_date}"
|
f" {invoice.payment_hash} to {expiration_date}"
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
"""
|
||||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
UPDATE apipayments SET expiry = ?
|
||||||
WHERE checking_id = :checking_id AND amount > 0
|
WHERE checking_id = ? AND amount > 0
|
||||||
""",
|
""",
|
||||||
{"expiry": expiration_date, "checking_id": checking_id},
|
(
|
||||||
|
db.datetime_to_timestamp(expiration_date),
|
||||||
|
checking_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -340,34 +347,30 @@ async def m014_set_deleted_wallets(db):
|
|||||||
Sets deleted column to wallets.
|
Sets deleted column to wallets.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
rows = await (
|
||||||
"""
|
await db.execute(
|
||||||
SELECT *
|
"""
|
||||||
FROM wallets
|
SELECT *
|
||||||
WHERE user LIKE 'del:%'
|
FROM wallets
|
||||||
AND adminkey LIKE 'del:%'
|
WHERE user LIKE 'del:%'
|
||||||
AND inkey LIKE 'del:%'
|
AND adminkey LIKE 'del:%'
|
||||||
"""
|
AND inkey LIKE 'del:%'
|
||||||
)
|
"""
|
||||||
rows = result.mappings().all()
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
user = row["user"].split(":")[1]
|
user = row[2].split(":")[1]
|
||||||
adminkey = row["adminkey"].split(":")[1]
|
adminkey = row[3].split(":")[1]
|
||||||
inkey = row["inkey"].split(":")[1]
|
inkey = row[4].split(":")[1]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE wallets SET
|
UPDATE wallets SET
|
||||||
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
|
"user" = ?, adminkey = ?, inkey = ?, deleted = true
|
||||||
WHERE id = :wallet
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
{
|
(user, adminkey, inkey, row[0]),
|
||||||
"user": user,
|
|
||||||
"adminkey": adminkey,
|
|
||||||
"inkey": inkey,
|
|
||||||
"wallet": row.get("id"),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -453,17 +456,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
|||||||
now = int(time())
|
now = int(time())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""",
|
""",
|
||||||
{"now": now},
|
(now,),
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""",
|
""",
|
||||||
{"now": now},
|
(now,),
|
||||||
)
|
)
|
||||||
|
|
||||||
except OperationalError as exc:
|
except OperationalError as exc:
|
||||||
@@ -543,79 +546,5 @@ async def m021_add_success_failed_to_apipayments(db):
|
|||||||
GROUP BY apipayments.wallet
|
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"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
+81
-136
@@ -1,28 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlite3 import Row
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from ecdsa import SECP256k1, SigningKey
|
from ecdsa import SECP256k1, SigningKey
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from passlib.context import CryptContext
|
from pydantic import BaseModel, validator
|
||||||
from pydantic import BaseModel, Field, validator
|
|
||||||
|
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel, FromRowModel
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.lnurl import encode as lnurl_encode
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
from lnbits.wallets.base import (
|
from lnbits.wallets.base import (
|
||||||
PaymentFailedStatus,
|
|
||||||
PaymentPendingStatus,
|
PaymentPendingStatus,
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
PaymentSuccessStatus,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,21 +34,16 @@ class BaseWallet(BaseModel):
|
|||||||
balance_msat: int
|
balance_msat: int
|
||||||
|
|
||||||
|
|
||||||
class Wallet(BaseModel):
|
class Wallet(BaseWallet):
|
||||||
id: str
|
|
||||||
user: str
|
user: str
|
||||||
name: str
|
currency: Optional[str]
|
||||||
adminkey: str
|
deleted: bool
|
||||||
inkey: str
|
created_at: Optional[int] = None
|
||||||
deleted: bool = False
|
updated_at: Optional[int] = None
|
||||||
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
|
@property
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
return int(self.balance_msat // 1000)
|
return self.balance_msat // 1000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def withdrawable_balance(self) -> int:
|
def withdrawable_balance(self) -> int:
|
||||||
@@ -72,6 +67,11 @@ class Wallet(BaseModel):
|
|||||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
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):
|
class KeyType(Enum):
|
||||||
admin = 0
|
admin = 0
|
||||||
@@ -89,7 +89,7 @@ class WalletTypeInfo:
|
|||||||
wallet: Wallet
|
wallet: Wallet
|
||||||
|
|
||||||
|
|
||||||
class UserExtra(BaseModel):
|
class UserConfig(BaseModel):
|
||||||
email_verified: Optional[bool] = False
|
email_verified: Optional[bool] = False
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
@@ -102,43 +102,16 @@ class UserExtra(BaseModel):
|
|||||||
provider: Optional[str] = "lnbits" # auth provider
|
provider: Optional[str] = "lnbits" # auth provider
|
||||||
|
|
||||||
|
|
||||||
class Account(BaseModel):
|
class Account(FromRowModel):
|
||||||
id: str
|
id: str
|
||||||
|
is_super_user: Optional[bool] = False
|
||||||
|
is_admin: Optional[bool] = False
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
password_hash: Optional[str] = None
|
|
||||||
pubkey: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
extra: UserExtra = UserExtra()
|
balance_msat: Optional[int] = 0
|
||||||
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
|
transaction_count: Optional[int] = 0
|
||||||
wallet_count: Optional[int] = 0
|
wallet_count: Optional[int] = 0
|
||||||
balance_msat: Optional[int] = 0
|
last_payment: Optional[datetime.datetime] = None
|
||||||
last_payment: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountFilters(FilterModel):
|
class AccountFilters(FilterModel):
|
||||||
@@ -153,7 +126,7 @@ class AccountFilters(FilterModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
last_payment: Optional[datetime] = None
|
last_payment: Optional[datetime.datetime] = None
|
||||||
transaction_count: Optional[int] = None
|
transaction_count: Optional[int] = None
|
||||||
wallet_count: Optional[int] = None
|
wallet_count: Optional[int] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
@@ -162,17 +135,16 @@ class AccountFilters(FilterModel):
|
|||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
pubkey: Optional[str] = None
|
|
||||||
extensions: list[str] = []
|
extensions: list[str] = []
|
||||||
wallets: list[Wallet] = []
|
wallets: list[Wallet] = []
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
super_user: bool = False
|
super_user: bool = False
|
||||||
has_password: bool = False
|
has_password: bool = False
|
||||||
extra: UserExtra = UserExtra()
|
config: Optional[UserConfig] = None
|
||||||
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_ids(self) -> list[str]:
|
def wallet_ids(self) -> list[str]:
|
||||||
@@ -204,26 +176,15 @@ class UpdateUser(BaseModel):
|
|||||||
user_id: str
|
user_id: str
|
||||||
email: Optional[str] = Query(default=None)
|
email: Optional[str] = Query(default=None)
|
||||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||||
extra: Optional[UserExtra] = None
|
config: Optional[UserConfig] = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserPassword(BaseModel):
|
class UpdateUserPassword(BaseModel):
|
||||||
user_id: str
|
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: str = Query(default=..., min_length=8, max_length=50)
|
||||||
password_repeat: 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):
|
class UpdateSuperuserPassword(BaseModel):
|
||||||
@@ -241,13 +202,6 @@ class LoginUsernamePassword(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenPayload(BaseModel):
|
|
||||||
sub: str
|
|
||||||
usr: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
auth_time: Optional[int] = 0
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentState(str, Enum):
|
class PaymentState(str, Enum):
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
SUCCESS = "success"
|
SUCCESS = "success"
|
||||||
@@ -257,55 +211,23 @@ class PaymentState(str, Enum):
|
|||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class PaymentExtra(BaseModel):
|
class Payment(FromRowModel):
|
||||||
comment: Optional[str] = None
|
status: str
|
||||||
success_action: Optional[str] = None
|
# TODO should be removed in the future, backward compatibility
|
||||||
lnurl_response: Optional[str] = None
|
pending: bool
|
||||||
|
|
||||||
|
|
||||||
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_hash: str
|
|
||||||
bolt11: str
|
|
||||||
amount_msat: int
|
|
||||||
memo: str
|
|
||||||
extra: Optional[dict] = {}
|
|
||||||
preimage: Optional[str] = None
|
|
||||||
expiry: Optional[datetime] = None
|
|
||||||
webhook: Optional[str] = None
|
|
||||||
fee: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Payment(BaseModel):
|
|
||||||
checking_id: str
|
checking_id: str
|
||||||
payment_hash: str
|
|
||||||
wallet_id: str
|
|
||||||
amount: int
|
amount: int
|
||||||
fee: int
|
fee: int
|
||||||
|
memo: Optional[str]
|
||||||
|
time: int
|
||||||
bolt11: str
|
bolt11: str
|
||||||
status: str = PaymentState.PENDING
|
preimage: str
|
||||||
memo: Optional[str] = None
|
payment_hash: str
|
||||||
expiry: Optional[datetime] = None
|
expiry: Optional[float]
|
||||||
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 = {}
|
extra: dict = {}
|
||||||
|
wallet_id: str
|
||||||
@property
|
webhook: Optional[str]
|
||||||
def pending(self) -> bool:
|
webhook_status: Optional[int]
|
||||||
return self.status == PaymentState.PENDING.value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def success(self) -> bool:
|
def success(self) -> bool:
|
||||||
@@ -315,6 +237,33 @@ class Payment(BaseModel):
|
|||||||
def failed(self) -> bool:
|
def failed(self) -> bool:
|
||||||
return self.status == PaymentState.FAILED.value
|
return self.status == PaymentState.FAILED.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
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
|
@property
|
||||||
def msat(self) -> int:
|
def msat(self) -> int:
|
||||||
return self.amount
|
return self.amount
|
||||||
@@ -333,18 +282,14 @@ class Payment(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
|
return self.expiry < time.time() if self.expiry else False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_internal(self) -> bool:
|
def is_uncheckable(self) -> bool:
|
||||||
return self.checking_id.startswith("internal_")
|
return self.checking_id.startswith("internal_")
|
||||||
|
|
||||||
async def check_status(self) -> PaymentStatus:
|
async def check_status(self) -> PaymentStatus:
|
||||||
if self.is_internal:
|
if self.is_uncheckable:
|
||||||
if self.success:
|
|
||||||
return PaymentSuccessStatus()
|
|
||||||
if self.failed:
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
funding_source = get_funding_source()
|
funding_source = get_funding_source()
|
||||||
if self.is_out:
|
if self.is_out:
|
||||||
@@ -361,11 +306,11 @@ class PaymentFilters(FilterModel):
|
|||||||
amount: int
|
amount: int
|
||||||
fee: int
|
fee: int
|
||||||
memo: Optional[str]
|
memo: Optional[str]
|
||||||
time: datetime
|
time: datetime.datetime
|
||||||
bolt11: str
|
bolt11: str
|
||||||
preimage: str
|
preimage: str
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
expiry: Optional[datetime]
|
expiry: Optional[datetime.datetime]
|
||||||
extra: dict = {}
|
extra: dict = {}
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
webhook: Optional[str]
|
webhook: Optional[str]
|
||||||
@@ -373,7 +318,7 @@ class PaymentFilters(FilterModel):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentHistoryPoint(BaseModel):
|
class PaymentHistoryPoint(BaseModel):
|
||||||
date: datetime
|
date: datetime.datetime
|
||||||
income: int
|
income: int
|
||||||
spending: int
|
spending: int
|
||||||
balance: int
|
balance: int
|
||||||
@@ -395,6 +340,10 @@ class TinyURL(BaseModel):
|
|||||||
wallet: str
|
wallet: str
|
||||||
time: float
|
time: float
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
class ConversionData(BaseModel):
|
class ConversionData(BaseModel):
|
||||||
from_: str = "sat"
|
from_: str = "sat"
|
||||||
@@ -439,6 +388,7 @@ class CreateInvoice(BaseModel):
|
|||||||
def unit_is_from_allowed_currencies(cls, v):
|
def unit_is_from_allowed_currencies(cls, v):
|
||||||
if v != "sat" and v not in allowed_currencies():
|
if v != "sat" and v not in allowed_currencies():
|
||||||
raise ValueError("The provided unit is not supported")
|
raise ValueError("The provided unit is not supported")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@@ -464,7 +414,7 @@ class WebPushSubscription(BaseModel):
|
|||||||
user: str
|
user: str
|
||||||
data: str
|
data: str
|
||||||
host: str
|
host: str
|
||||||
timestamp: datetime
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
class BalanceDelta(BaseModel):
|
class BalanceDelta(BaseModel):
|
||||||
@@ -479,8 +429,3 @@ class BalanceDelta(BaseModel):
|
|||||||
class SimpleStatus(BaseModel):
|
class SimpleStatus(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class DbVersion(BaseModel):
|
|
||||||
db: str
|
|
||||||
version: int
|
|
||||||
|
|||||||
+356
-362
@@ -1,23 +1,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from bolt11 import MilliSatoshi
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from bolt11.types import Bolt11
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from fastapi import Depends, WebSocket
|
from fastapi import Depends, WebSocket
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from passlib.context import CryptContext
|
||||||
from py_vapid import Vapid
|
from py_vapid import Vapid
|
||||||
from py_vapid.utils import b64urlencode
|
from py_vapid.utils import b64urlencode
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.extensions.models import UserExtension
|
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
@@ -46,54 +47,48 @@ from lnbits.wallets.base import (
|
|||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
check_internal,
|
check_internal,
|
||||||
|
check_internal_pending,
|
||||||
create_account,
|
create_account,
|
||||||
create_admin_settings,
|
create_admin_settings,
|
||||||
create_payment,
|
create_payment,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
get_account,
|
get_account,
|
||||||
get_account_by_email,
|
get_account_by_email,
|
||||||
get_account_by_pubkey,
|
|
||||||
get_account_by_username,
|
get_account_by_username,
|
||||||
get_payments,
|
get_payments,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_super_settings,
|
get_super_settings,
|
||||||
get_total_balance,
|
get_total_balance,
|
||||||
get_user_from_account,
|
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallet_payment,
|
get_wallet_payment,
|
||||||
is_internal_status_success,
|
|
||||||
update_admin_settings,
|
update_admin_settings,
|
||||||
update_payment,
|
update_payment_details,
|
||||||
|
update_payment_status,
|
||||||
update_super_user,
|
update_super_user,
|
||||||
update_user_extension,
|
update_user_extension,
|
||||||
)
|
)
|
||||||
from .helpers import to_valid_user_id
|
from .helpers import to_valid_user_id
|
||||||
from .models import (
|
from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet
|
||||||
Account,
|
|
||||||
BalanceDelta,
|
|
||||||
CreatePayment,
|
|
||||||
Payment,
|
|
||||||
PaymentState,
|
|
||||||
User,
|
|
||||||
UserExtra,
|
|
||||||
Wallet,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def calculate_fiat_amounts(
|
async def calculate_fiat_amounts(
|
||||||
amount: float,
|
amount: float,
|
||||||
wallet: Wallet,
|
wallet_id: str,
|
||||||
currency: Optional[str] = None,
|
currency: Optional[str] = None,
|
||||||
extra: Optional[dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
) -> tuple[int, dict]:
|
conn: Optional[Connection] = None,
|
||||||
|
) -> Tuple[int, Optional[Dict]]:
|
||||||
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
|
assert wallet, "invalid wallet_id"
|
||||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||||
fiat_amounts: dict = extra or {}
|
|
||||||
if currency and currency != "sat":
|
if currency and currency != "sat":
|
||||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||||
|
extra = extra or {}
|
||||||
if currency != wallet_currency:
|
if currency != wallet_currency:
|
||||||
fiat_amounts["fiat_currency"] = currency
|
extra["fiat_currency"] = currency
|
||||||
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
|
extra["fiat_amount"] = round(amount, ndigits=3)
|
||||||
fiat_amounts["fiat_rate"] = amount_sat / amount
|
extra["fiat_rate"] = amount_sat / amount
|
||||||
else:
|
else:
|
||||||
amount_sat = int(amount)
|
amount_sat = int(amount)
|
||||||
|
|
||||||
@@ -102,15 +97,16 @@ async def calculate_fiat_amounts(
|
|||||||
fiat_amount = amount
|
fiat_amount = amount
|
||||||
else:
|
else:
|
||||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||||
fiat_amounts["wallet_fiat_currency"] = wallet_currency
|
extra = extra or {}
|
||||||
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
extra["wallet_fiat_currency"] = wallet_currency
|
||||||
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
|
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||||
|
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
|
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return amount_sat, fiat_amounts
|
return amount_sat, extra
|
||||||
|
|
||||||
|
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
@@ -122,11 +118,11 @@ async def create_invoice(
|
|||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
expiry: Optional[int] = None,
|
expiry: Optional[int] = None,
|
||||||
extra: Optional[dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
webhook: Optional[str] = None,
|
webhook: Optional[str] = None,
|
||||||
internal: Optional[bool] = False,
|
internal: Optional[bool] = False,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Tuple[str, str]:
|
||||||
if not amount > 0:
|
if not amount > 0:
|
||||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||||
|
|
||||||
@@ -140,7 +136,7 @@ async def create_invoice(
|
|||||||
funding_source = fake_wallet if internal else get_funding_source()
|
funding_source = fake_wallet if internal else get_funding_source()
|
||||||
|
|
||||||
amount_sat, extra = await calculate_fiat_amounts(
|
amount_sat, extra = await calculate_fiat_amounts(
|
||||||
amount, user_wallet, currency, extra
|
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.is_wallet_max_balance_exceeded(
|
if settings.is_wallet_max_balance_exceeded(
|
||||||
@@ -171,193 +167,21 @@ async def create_invoice(
|
|||||||
|
|
||||||
invoice = bolt11_decode(payment_request)
|
invoice = bolt11_decode(payment_request)
|
||||||
|
|
||||||
create_payment_model = CreatePayment(
|
amount_msat = 1000 * amount_sat
|
||||||
|
await create_payment(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
bolt11=payment_request,
|
checking_id=checking_id,
|
||||||
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
amount_msat=amount_sat * 1000,
|
amount=amount_msat,
|
||||||
expiry=invoice.expiry_date,
|
expiry=invoice.expiry_date,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
webhook=webhook,
|
webhook=webhook,
|
||||||
)
|
|
||||||
|
|
||||||
payment = await create_payment(
|
|
||||||
checking_id=checking_id,
|
|
||||||
data=create_payment_model,
|
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
|
||||||
return payment
|
return invoice.payment_hash, payment_request
|
||||||
|
|
||||||
|
|
||||||
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(
|
async def pay_invoice(
|
||||||
@@ -365,68 +189,20 @@ async def pay_invoice(
|
|||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
payment_request: str,
|
payment_request: str,
|
||||||
max_sat: Optional[int] = None,
|
max_sat: Optional[int] = None,
|
||||||
extra: Optional[dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
tag: str = "",
|
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> str:
|
||||||
invoice = _validate_payment_request(payment_request, max_sat)
|
"""
|
||||||
assert invoice.amount_msat
|
Pay a Lightning invoice.
|
||||||
|
First, we create a temporary payment in the database with fees set to the reserve
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
fee. We then check whether the balance of the payer would go negative.
|
||||||
amount_msat = invoice.amount_msat
|
We then attempt to pay the invoice through the backend. If the payment is
|
||||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
|
successful, we update the payment in the database with the payment details.
|
||||||
|
If the payment is unsuccessful, we delete the temporary payment.
|
||||||
if await is_internal_status_success(invoice.payment_hash, conn):
|
If the payment is still in flight, we hope that some other process
|
||||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
will regularly check for the payment.
|
||||||
|
"""
|
||||||
_, 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:
|
try:
|
||||||
invoice = bolt11_decode(payment_request)
|
invoice = bolt11_decode(payment_request)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -434,70 +210,283 @@ def _validate_payment_request(
|
|||||||
|
|
||||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||||
|
|
||||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||||
raise PaymentError("Amount in invoice is too high.", status="failed")
|
raise PaymentError("Amount in invoice is too high.", status="failed")
|
||||||
|
|
||||||
return invoice
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# put all parameters that don't change here
|
||||||
|
class PaymentKwargs(TypedDict):
|
||||||
|
wallet_id: str
|
||||||
|
payment_request: str
|
||||||
|
payment_hash: str
|
||||||
|
amount: int
|
||||||
|
memo: str
|
||||||
|
expiry: Optional[datetime.datetime]
|
||||||
|
extra: Optional[Dict]
|
||||||
|
|
||||||
|
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||||
|
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:
|
||||||
|
fee_reserve_total_msat = fee_reserve_total(
|
||||||
|
invoice.amount_msat, internal=True
|
||||||
|
)
|
||||||
|
# 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
|
||||||
|
new_payment = await create_payment(
|
||||||
|
checking_id=internal_id,
|
||||||
|
fee=0 + abs(fee_reserve_total_msat),
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
conn=conn,
|
||||||
|
**payment_kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_payment = await _create_external_payment(
|
||||||
|
temp_id, invoice.amount_msat, conn=conn, **payment_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
new_payment = await create_payment(
|
||||||
|
wallet_id=settings.lnbits_service_fee_wallet,
|
||||||
|
fee=0,
|
||||||
|
amount=abs(service_fee_msat),
|
||||||
|
memo="Service fee",
|
||||||
|
checking_id="service_fee" + temp_id,
|
||||||
|
payment_request=payment_request,
|
||||||
|
payment_hash=invoice.payment_hash,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
)
|
||||||
|
return invoice.payment_hash
|
||||||
|
|
||||||
|
|
||||||
async def _credit_service_fee_wallet(
|
async def _create_external_payment(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
temp_id: str,
|
||||||
|
amount_msat: MilliSatoshi,
|
||||||
|
conn: Optional[Connection],
|
||||||
|
**payment_kwargs,
|
||||||
|
) -> 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:
|
||||||
|
new_payment = await create_payment(
|
||||||
|
checking_id=temp_id,
|
||||||
|
fee=-abs(fee_reserve_total_msat),
|
||||||
|
conn=conn,
|
||||||
|
**payment_kwargs,
|
||||||
|
)
|
||||||
|
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,
|
||||||
):
|
):
|
||||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
if wallet.balance_msat < 0:
|
||||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
logger.debug("balance is too low, deleting temporary payment")
|
||||||
return
|
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
|
||||||
|
raise PaymentError(
|
||||||
create_payment_model = CreatePayment(
|
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||||
wallet_id=settings.lnbits_service_fee_wallet,
|
" sat) to cover potential routing fees.",
|
||||||
bolt11=payment.bolt11,
|
status="failed",
|
||||||
payment_hash=payment.payment_hash,
|
)
|
||||||
amount_msat=abs(service_fee_msat),
|
raise PaymentError("Insufficient balance.", status="failed")
|
||||||
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(
|
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
await check_time_limit_between_transactions(conn, wallet_id)
|
||||||
):
|
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||||
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(
|
async def check_time_limit_between_transactions(conn, wallet_id):
|
||||||
wallet_id: str, conn: Optional[Connection] = None
|
|
||||||
):
|
|
||||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||||
if not limit or limit <= 0:
|
if not limit or limit <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
payments = await get_payments(
|
payments = await get_payments(
|
||||||
since=int(time.time()) - limit,
|
since=int(time.time()) - limit,
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
limit=1,
|
limit=1,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(payments) == 0:
|
if len(payments) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
raise PaymentError(
|
raise PaymentError(
|
||||||
status="failed",
|
status="failed",
|
||||||
message=f"The time limit of {limit} seconds between payments has been reached.",
|
message=f"The time limit of {limit} seconds between payments has been reached.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_wallet_daily_withdraw_limit(
|
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
|
||||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
|
||||||
):
|
|
||||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||||
if not limit:
|
if not limit or limit <= 0:
|
||||||
return
|
return
|
||||||
if limit < 0:
|
|
||||||
raise ValueError("It is not allowed to spend funds from this server.")
|
|
||||||
|
|
||||||
payments = await get_payments(
|
payments = await get_payments(
|
||||||
since=int(time.time()) - 60 * 60 * 24,
|
since=int(time.time()) - 60 * 60 * 24,
|
||||||
@@ -525,7 +514,7 @@ async def redeem_lnurl_withdraw(
|
|||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
lnurl_request: str,
|
lnurl_request: str,
|
||||||
memo: Optional[str] = None,
|
memo: Optional[str] = None,
|
||||||
extra: Optional[dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
wait_seconds: int = 0,
|
wait_seconds: int = 0,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -681,7 +670,6 @@ def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def service_fee(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
|
service_fee_percent = settings.lnbits_service_fee
|
||||||
fee_max = settings.lnbits_service_fee_max * 1000
|
fee_max = settings.lnbits_service_fee_max * 1000
|
||||||
if settings.lnbits_service_fee_wallet:
|
if settings.lnbits_service_fee_wallet:
|
||||||
@@ -701,33 +689,38 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
await websocket_manager.send_data(payment.json(), wallet.inkey)
|
await websocket_updater(
|
||||||
# json.dumps(
|
wallet.inkey,
|
||||||
# {
|
json.dumps(
|
||||||
# "wallet_balance": wallet.balance,
|
{
|
||||||
# "payment": payment,
|
"wallet_balance": wallet.balance,
|
||||||
# }
|
"payment": payment.dict(),
|
||||||
# ),
|
}
|
||||||
await websocket_manager.send_data(
|
),
|
||||||
json.dumps({"pending": payment.pending}), payment.payment_hash
|
)
|
||||||
|
|
||||||
|
await websocket_updater(
|
||||||
|
payment.payment_hash, json.dumps({"pending": payment.pending})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
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:
|
async with db.connect() as conn:
|
||||||
payment = await create_invoice(
|
checking_id = await check_internal(payment_hash, conn=conn)
|
||||||
wallet_id=wallet_id,
|
assert checking_id, "newly created checking_id cannot be retrieved"
|
||||||
amount=amount,
|
await update_payment_status(
|
||||||
memo="Admin top up",
|
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
|
||||||
internal=True,
|
|
||||||
conn=conn,
|
|
||||||
)
|
)
|
||||||
payment.status = PaymentState.SUCCESS
|
|
||||||
await update_payment(payment, conn=conn)
|
|
||||||
# notify receiver asynchronously
|
# notify receiver asynchronously
|
||||||
from lnbits.tasks import internal_invoice_queue
|
from lnbits.tasks import internal_invoice_queue
|
||||||
|
|
||||||
await internal_invoice_queue.put(payment.checking_id)
|
await internal_invoice_queue.put(checking_id)
|
||||||
|
|
||||||
|
|
||||||
async def check_admin_settings():
|
async def check_admin_settings():
|
||||||
@@ -761,7 +754,7 @@ async def check_admin_settings():
|
|||||||
send_admin_user_to_saas()
|
send_admin_user_to_saas()
|
||||||
|
|
||||||
account = await get_account(settings.super_user)
|
account = await get_account(settings.super_user)
|
||||||
if account and account.extra and account.extra.provider == "env":
|
if account and account.config and account.config.provider == "env":
|
||||||
settings.first_install = True
|
settings.first_install = True
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
@@ -812,58 +805,55 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
|||||||
if super_user:
|
if super_user:
|
||||||
account = await get_account(super_user)
|
account = await get_account(super_user)
|
||||||
if not account:
|
if not account:
|
||||||
account_id = super_user or uuid4().hex
|
account = await create_account(
|
||||||
account = Account(
|
user_id=super_user, user_config=UserConfig(provider="env")
|
||||||
id=account_id,
|
|
||||||
extra=UserExtra(provider="env"),
|
|
||||||
)
|
)
|
||||||
await create_account(account)
|
if not account.wallets or len(account.wallets) == 0:
|
||||||
await create_wallet(user_id=account.id)
|
await create_wallet(user_id=account.id)
|
||||||
|
|
||||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||||
|
|
||||||
return await create_admin_settings(account.id, editable_settings.dict())
|
return await create_admin_settings(account.id, editable_settings.dict())
|
||||||
|
|
||||||
|
|
||||||
async def create_user_account(
|
async def create_user_account(
|
||||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
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,
|
||||||
) -> User:
|
) -> User:
|
||||||
if not settings.new_accounts_allowed:
|
if not settings.new_accounts_allowed:
|
||||||
raise ValueError("Account creation is disabled.")
|
raise ValueError("Account creation is disabled.")
|
||||||
if account:
|
if username and await get_account_by_username(username):
|
||||||
if account.username and await get_account_by_username(account.username):
|
raise ValueError("Username already exists.")
|
||||||
raise ValueError("Username already exists.")
|
|
||||||
|
|
||||||
if account.email and await get_account_by_email(account.email):
|
if email and await get_account_by_email(email):
|
||||||
raise ValueError("Email already exists.")
|
raise ValueError("Email already exists.")
|
||||||
|
|
||||||
if account.pubkey and await get_account_by_pubkey(account.pubkey):
|
if user_id:
|
||||||
raise ValueError("Pubkey already exists.")
|
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.id:
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
user_uuid4 = UUID(hex=account.id, version=4)
|
password = pwd_context.hash(password) if password else None
|
||||||
assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string"
|
|
||||||
else:
|
|
||||||
account.id = uuid4().hex
|
|
||||||
|
|
||||||
account = await create_account(account)
|
account = await create_account(user_id, username, email, password, user_config)
|
||||||
await create_wallet(
|
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
|
||||||
user_id=account.id,
|
account.wallets = [wallet]
|
||||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
for ext_id in settings.lnbits_user_default_extensions:
|
for ext_id in settings.lnbits_user_default_extensions:
|
||||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
|
||||||
await update_user_extension(user_ext)
|
|
||||||
|
|
||||||
user = await get_user_from_account(account)
|
return account
|
||||||
assert user, "Cannot find user for account."
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketConnectionManager:
|
class WebsocketConnectionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.active_connections: list[WebSocket] = []
|
self.active_connections: List[WebSocket] = []
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, item_id: str):
|
async def connect(self, websocket: WebSocket, item_id: str):
|
||||||
logger.debug(f"Websocket connected to {item_id}")
|
logger.debug(f"Websocket connected to {item_id}")
|
||||||
@@ -882,8 +872,8 @@ class WebsocketConnectionManager:
|
|||||||
websocket_manager = WebsocketConnectionManager()
|
websocket_manager = WebsocketConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
async def websocket_updater(item_id: str, data: str):
|
async def websocket_updater(item_id, data):
|
||||||
return await websocket_manager.send_data(data, item_id)
|
return await websocket_manager.send_data(f"{data}", item_id)
|
||||||
|
|
||||||
|
|
||||||
async def switch_to_voidwallet() -> None:
|
async def switch_to_voidwallet() -> None:
|
||||||
@@ -913,8 +903,12 @@ async def update_pending_payments(wallet_id: str):
|
|||||||
for payment in pending_payments:
|
for payment in pending_payments:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
if status.failed:
|
if status.failed:
|
||||||
payment.status = PaymentState.FAILED
|
await update_payment_status(
|
||||||
await update_payment(payment)
|
checking_id=payment.checking_id,
|
||||||
|
status=PaymentState.FAILED,
|
||||||
|
)
|
||||||
elif status.success:
|
elif status.success:
|
||||||
payment.status = PaymentState.SUCCESS
|
await update_payment_status(
|
||||||
await update_payment(payment)
|
checking_id=payment.checking_id,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,40 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</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
|
<q-card-section
|
||||||
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
||||||
class="q-pl-xl"
|
class="q-pl-xl"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
:label="$t('restart')"
|
:label="$t('restart')"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="restartServer"
|
@click="restartServer"
|
||||||
class="q-ml-md"
|
|
||||||
>
|
>
|
||||||
<q-tooltip v-if="needsRestart">
|
<q-tooltip v-if="needsRestart">
|
||||||
<span v-text="$t('restart_tooltip')"></span>
|
<span v-text="$t('restart_tooltip')"></span>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<q-item dense class="q-pa-none">
|
<q-item dense class="q-pa-none">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
|
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon
|
<q-icon
|
||||||
name="content_copy"
|
name="content_copy"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@click="copyText(wallet.id)"
|
@click="copyText('{{ wallet.id }}')"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<q-item-label>
|
<q-item-label>
|
||||||
<strong>Admin key: </strong
|
<strong>Admin key: </strong
|
||||||
><em
|
><em
|
||||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
|
||||||
></em>
|
></em>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
@@ -55,7 +55,9 @@
|
|||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
<strong>Invoice/read key: </strong
|
<strong>Invoice/read key: </strong
|
||||||
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
|
><em
|
||||||
|
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
|
||||||
|
></em>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
<q-icon
|
<q-icon
|
||||||
name="content_copy"
|
name="content_copy"
|
||||||
class="cursor-pointer q-ml-sm"
|
class="cursor-pointer q-ml-sm"
|
||||||
@click="copyText(wallet.inkey)"
|
@click="copyText('{{ wallet.inkey }}')"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|||||||
@@ -26,9 +26,7 @@
|
|||||||
</q-tabs>
|
</q-tabs>
|
||||||
<q-tab-panels v-model="tab">
|
<q-tab-panels v-model="tab">
|
||||||
<q-tab-panel name="user">
|
<q-tab-panel name="user">
|
||||||
<div v-if="credentialsData.show">
|
<div v-if="passwordData.show">
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -38,26 +36,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-img
|
<q-img
|
||||||
v-if="user.extra.picture"
|
v-if="user.config.picture"
|
||||||
style="max-width: 100px"
|
style="max-width: 100px"
|
||||||
:src="user.extra.picture"
|
:src="user.config.picture"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-img>
|
></q-img>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-input
|
|
||||||
v-model="credentialsData.username"
|
|
||||||
:label="$t('username')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
:readonly="hasUsername"
|
|
||||||
class="q-mb-md"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-if="user.has_password"
|
v-if="user.has_password"
|
||||||
v-model="credentialsData.oldPassword"
|
v-model="passwordData.oldPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
label="Old Password"
|
label="Old Password"
|
||||||
@@ -66,7 +57,7 @@
|
|||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="credentialsData.newPassword"
|
v-model="passwordData.newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('password')"
|
:label="$t('password')"
|
||||||
@@ -75,7 +66,7 @@
|
|||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="credentialsData.newPasswordRepeat"
|
v-model="passwordData.newPasswordRepeat"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('password_repeat')"
|
:label="$t('password_repeat')"
|
||||||
@@ -84,47 +75,24 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section class="q-pb-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="updatePassword"
|
@click="updatePassword"
|
||||||
:disable="disableUpdatePassword()"
|
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
class="float-right"
|
|
||||||
:label="$t('change_password')"
|
:label="$t('change_password')"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</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
|
<q-btn
|
||||||
@click="updatePubkey"
|
@click="passwordData.show = false"
|
||||||
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')"
|
:label="$t('back')"
|
||||||
outline
|
outline
|
||||||
unelevated
|
unelevated
|
||||||
color="grey"
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,9 +101,9 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-img
|
<q-img
|
||||||
v-if="user.extra.picture"
|
v-if="user.config.picture"
|
||||||
style="max-width: 100px"
|
style="max-width: 100px"
|
||||||
:src="user.extra.picture"
|
:src="user.config.picture"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-img>
|
></q-img>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,15 +137,6 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
|
||||||
v-model="user.pubkey"
|
|
||||||
:label="$t('pubkey')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.email"
|
v-model="user.email"
|
||||||
:label="$t('email')"
|
:label="$t('email')"
|
||||||
@@ -236,9 +195,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="user.extra">
|
<q-card-section v-if="user.config">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.first_name"
|
v-model="user.config.first_name"
|
||||||
:label="$t('first_name')"
|
:label="$t('first_name')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -246,7 +205,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.last_name"
|
v-model="user.config.last_name"
|
||||||
:label="$t('last_name')"
|
:label="$t('last_name')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -254,7 +213,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.provider"
|
v-model="user.config.provider"
|
||||||
:label="$t('auth_provider')"
|
:label="$t('auth_provider')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -263,9 +222,10 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.picture"
|
v-model="user.config.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
filled
|
filled
|
||||||
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
@@ -276,10 +236,11 @@
|
|||||||
<span v-text="$t('update_account')"></span>
|
<span v-text="$t('update_account')"></span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="showUpdateCredentials()"
|
@click="showChangePassword()"
|
||||||
:label="$t('update_credentials')"
|
:label="user.has_password ? $t('change_password'): $t('set_password')"
|
||||||
filled
|
outline
|
||||||
color="primary"
|
unelevated
|
||||||
|
color="grey"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -470,7 +431,7 @@
|
|||||||
v-model="reactionChoice"
|
v-model="reactionChoice"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
label="Reactions"
|
label="Reactions"
|
||||||
@update:model-value="reactionChoiceFunc"
|
@input="reactionChoiceFunc"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('payment_reactions')"></span
|
><span v-text="$t('payment_reactions')"></span
|
||||||
|
|||||||
@@ -23,14 +23,26 @@
|
|||||||
<div class="q-pa-xs">
|
<div class="q-pa-xs">
|
||||||
<div class="q-gutter-y-md">
|
<div class="q-gutter-y-md">
|
||||||
<q-tabs
|
<q-tabs
|
||||||
:model-value="tab"
|
v-model="tab"
|
||||||
@update:model-value="handleTabChanged"
|
@input="handleTabChanged"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
align="left"
|
align="left"
|
||||||
>
|
>
|
||||||
<q-tab name="installed" :label="$t('installed')"></q-tab>
|
<q-tab
|
||||||
<q-tab name="all" :label="$t('all')"></q-tab>
|
name="installed"
|
||||||
<q-tab name="featured" :label="$t('featured')"></q-tab>
|
: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>
|
||||||
<i
|
<i
|
||||||
v-if="!g.user.admin && tab != 'installed'"
|
v-if="!g.user.admin && tab != 'installed'"
|
||||||
v-text="$t('only_admins_can_install')"
|
v-text="$t('only_admins_can_install')"
|
||||||
@@ -107,7 +119,7 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
style=""
|
style=""
|
||||||
v-model="extension.isActive"
|
v-model="extension.isActive"
|
||||||
@update:model-value="toggleExtension(extension)"
|
@input="toggleExtension(extension)"
|
||||||
><q-tooltip>
|
><q-tooltip>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -659,9 +671,11 @@
|
|||||||
<a
|
<a
|
||||||
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
||||||
>
|
>
|
||||||
<lnbits-qrcode
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
<lnbits-qrcode
|
||||||
></lnbits-qrcode>
|
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="col">
|
<div v-else class="col">
|
||||||
@@ -785,7 +799,7 @@
|
|||||||
swipeable
|
swipeable
|
||||||
animated
|
animated
|
||||||
v-model="slide"
|
v-model="slide"
|
||||||
v-model:fullscreen="fullscreen"
|
:fullscreen.sync="fullscreen"
|
||||||
thumbnails
|
thumbnails
|
||||||
infinite
|
infinite
|
||||||
:autoplay="autoplay"
|
:autoplay="autoplay"
|
||||||
@@ -887,7 +901,7 @@
|
|||||||
</q-dialog>
|
</q-dialog>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
|
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -1018,7 +1032,7 @@
|
|||||||
this.filteredExtensions = this.extensions.concat([])
|
this.filteredExtensions = this.extensions.concat([])
|
||||||
this.handleTabChanged('installed')
|
this.handleTabChanged('installed')
|
||||||
this.tab = 'installed'
|
this.tab = 'installed'
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension uninstalled!'
|
message: 'Extension uninstalled!'
|
||||||
})
|
})
|
||||||
@@ -1048,7 +1062,7 @@
|
|||||||
extension.installedRelease = null
|
extension.installedRelease = null
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
extension.hasDatabaseTables = false
|
extension.hasDatabaseTables = false
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension DB deleted!'
|
message: 'Extension DB deleted!'
|
||||||
})
|
})
|
||||||
@@ -1058,7 +1072,7 @@
|
|||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleExtension(extension) {
|
toggleExtension: function (extension) {
|
||||||
const action = extension.isActive ? 'activate' : 'deactivate'
|
const action = extension.isActive ? 'activate' : 'deactivate'
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
@@ -1067,7 +1081,7 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: `Extension '${extension.id}' ${action}d!`
|
message: `Extension '${extension.id}' ${action}d!`
|
||||||
})
|
})
|
||||||
@@ -1092,7 +1106,7 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension enabled!'
|
message: 'Extension enabled!'
|
||||||
})
|
})
|
||||||
@@ -1113,7 +1127,7 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension disabled!'
|
message: 'Extension disabled!'
|
||||||
})
|
})
|
||||||
@@ -1146,7 +1160,7 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Payment info updated!'
|
message: 'Payment info updated!'
|
||||||
})
|
})
|
||||||
@@ -1328,7 +1342,7 @@
|
|||||||
ws.addEventListener('message', async ({data}) => {
|
ws.addEventListener('message', async ({data}) => {
|
||||||
const payment = JSON.parse(data)
|
const payment = JSON.parse(data)
|
||||||
if (payment.pending === false) {
|
if (payment.pending === false) {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Invoice Paid!'
|
message: 'Invoice Paid!'
|
||||||
})
|
})
|
||||||
@@ -1397,13 +1411,13 @@
|
|||||||
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||||
const resp = JSON.parse(data)
|
const resp = JSON.parse(data)
|
||||||
if (resp.paid) {
|
if (resp.paid) {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Invoice Paid!'
|
message: 'Invoice Paid!'
|
||||||
})
|
})
|
||||||
this.installExtension(this.selectedRelease)
|
this.installExtension(this.selectedRelease)
|
||||||
} else {
|
} else {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Invoice tracking lost!'
|
message: 'Invoice tracking lost!'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,9 +97,9 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
loginData: {
|
loginData: {
|
||||||
|
|||||||
@@ -184,47 +184,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card-section>
|
</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 %}
|
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
||||||
@@ -271,28 +230,7 @@
|
|||||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||||
>
|
>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
|
{% if "google-auth" 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">
|
<div class="col-12 full-width q-pa-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
href="/api/v1/auth/google"
|
href="/api/v1/auth/google"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
|
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block page %}
|
{% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<strong v-text="formattedBalance"></strong>
|
<strong v-text="formattedBalance"></strong>
|
||||||
<small>{{LNBITS_DENOMINATION}}</small>
|
<small>{{LNBITS_DENOMINATION}}</small>
|
||||||
<lnbits-update-balance
|
<lnbits-update-balance
|
||||||
|
v-if="'{{user.super_user}}' == 'True'"
|
||||||
:wallet_id="this.g.wallet.id"
|
:wallet_id="this.g.wallet.id"
|
||||||
flat
|
flat
|
||||||
:callback="updateBalanceCallback"
|
:callback="updateBalanceCallback"
|
||||||
@@ -153,7 +154,10 @@
|
|||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p v-text="$t('export_to_phone_desc')"></p>
|
<p v-text="$t('export_to_phone_desc')"></p>
|
||||||
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
|
<qrcode
|
||||||
|
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||||
|
:options="{ width: 256 }"
|
||||||
|
></qrcode>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions class="flex-center q-pb-md">
|
<q-card-actions class="flex-center q-pb-md">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -366,9 +370,11 @@
|
|||||||
>
|
>
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<a :href="'lightning:' + receive.paymentReq">
|
<a :href="'lightning:' + receive.paymentReq">
|
||||||
<lnbits-qrcode
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
<lnbits-qrcode
|
||||||
></lnbits-qrcode>
|
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
@@ -621,8 +627,8 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<q-responsive :ratio="1">
|
<q-responsive :ratio="1">
|
||||||
<qrcode-stream
|
<qrcode-stream
|
||||||
@detect="decodeQR"
|
@decode="decodeQR"
|
||||||
@camera-on="onInitQR"
|
@init="onInitQR"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-stream>
|
></qrcode-stream>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
@@ -645,8 +651,8 @@
|
|||||||
<q-card class="q-pa-lg q-pt-xl">
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<qrcode-stream
|
<qrcode-stream
|
||||||
@detect="decodeQR"
|
@decode="decodeQR"
|
||||||
@camera-on="onInitQR"
|
@init="onInitQR"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-stream>
|
></qrcode-stream>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@
|
|||||||
>
|
>
|
||||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode-vue
|
<qrcode
|
||||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||||
:options="{width: 340}"
|
:options="{width: 340}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-vue>
|
></qrcode>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -94,9 +94,9 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:rows="paymentsTable.data"
|
:data="paymentsTable.data"
|
||||||
:columns="paymentsTable.columns"
|
:columns="paymentsTable.columns"
|
||||||
v-model:pagination="paymentsTable.pagination"
|
:pagination.sync="paymentsTable.pagination"
|
||||||
row-key="payment_hash"
|
row-key="payment_hash"
|
||||||
no-data-label="No transactions made yet"
|
no-data-label="No transactions made yet"
|
||||||
:filter="paymentsTable.filter"
|
:filter="paymentsTable.filter"
|
||||||
@@ -138,11 +138,11 @@
|
|||||||
>
|
>
|
||||||
<a :href="'lightning:' + props.row.bolt11">
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode-vue
|
<qrcode
|
||||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
:options="{width: 340}"
|
:options="{width: 340}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-vue>
|
></qrcode>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,9 +256,9 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:rows="invoiceTable.data"
|
:data="invoiceTable.data"
|
||||||
:columns="invoiceTable.columns"
|
:columns="invoiceTable.columns"
|
||||||
v-model:pagination="invoiceTable.pagination"
|
:pagination.sync="invoiceTable.pagination"
|
||||||
no-data-label="No transactions made yet"
|
no-data-label="No transactions made yet"
|
||||||
:filter="invoiceTable.filter"
|
:filter="invoiceTable.filter"
|
||||||
@request="getInvoices"
|
@request="getInvoices"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
Vue.component(VueQrcode.name, VueQrcode)
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
Vue.use(VueQrcodeReader)
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
config: {
|
config: {
|
||||||
globalProperties: {
|
globalProperties: {
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
msg: 'hello'
|
msg: 'hello'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -319,7 +319,7 @@
|
|||||||
.confirmDialog('Do you really wanna disconnect this peer?')
|
.confirmDialog('Do you really wanna disconnect this peer?')
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
this.api('DELETE', `/peers/${id}`).then(response => {
|
this.api('DELETE', `/peers/${id}`).then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: 'Disconnected',
|
message: 'Disconnected',
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ context %} {% block page %}
|
|||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
config: {
|
config: {
|
||||||
globalProperties: {
|
globalProperties: {
|
||||||
@@ -61,7 +64,7 @@ context %} {% block page %}
|
|||||||
msg: 'hello'
|
msg: 'hello'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<payment-list :wallet="activeWallet" />
|
<payment-list :wallet="activeWallet" />
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
<q-table :rows="wallets" :columns="walletTable.columns">
|
<q-table :data="wallets" :columns="walletTable.columns">
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
icon="content_copy"
|
icon="content_copy"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="copyText(props.row.id)"
|
@click="copyText(props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
v-if="!props.row.deleted"
|
v-if="!props.row.deleted"
|
||||||
:wallet_id="props.row.id"
|
:wallet_id="props.row.id"
|
||||||
:callback="topupCallback"
|
:callback="topupCallback"
|
||||||
class="q-ml-xs"
|
|
||||||
></lnbits-update-balance>
|
></lnbits-update-balance>
|
||||||
<q-btn
|
<q-btn
|
||||||
round
|
round
|
||||||
@@ -54,7 +52,6 @@
|
|||||||
icon="vpn_key"
|
icon="vpn_key"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="copyText(props.row.adminkey)"
|
@click="copyText(props.row.adminkey)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||||
@@ -65,7 +62,6 @@
|
|||||||
icon="vpn_key"
|
icon="vpn_key"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="copyText(props.row.inkey)"
|
@click="copyText(props.row.inkey)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||||
@@ -76,7 +72,6 @@
|
|||||||
icon="toggle_off"
|
icon="toggle_off"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||||
@@ -86,7 +81,6 @@
|
|||||||
icon="delete"
|
icon="delete"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="negative"
|
color="negative"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Delete Wallet</q-tooltip>
|
<q-tooltip>Delete Wallet</q-tooltip>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ include "users/_createWalletDialog.html" %}
|
|||||||
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
<div class="row q-col-gutter-md justify-center">
|
||||||
<div class="col q-gutter-y-md" style="width: 300px">
|
<div class="col q-gutter-y-md" style="width: 300px">
|
||||||
<div style="width: 100%; max-width: 2000px">
|
<div style="width: 600px">
|
||||||
<canvas ref="chart1"></canvas>
|
<canvas ref="chart1"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +24,10 @@ include "users/_createWalletDialog.html" %}
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<q-table
|
<q-table
|
||||||
row-key="id"
|
:data="users"
|
||||||
:rows="users"
|
:row-key="usersTableRowKey"
|
||||||
:columns="usersTable.columns"
|
:columns="usersTable.columns"
|
||||||
v-model:pagination="usersTable.pagination"
|
:pagination.sync="usersTable.pagination"
|
||||||
:no-data-label="$t('no_users')"
|
:no-data-label="$t('no_users')"
|
||||||
:filter="usersTable.search"
|
:filter="usersTable.search"
|
||||||
:loading="usersTable.loading"
|
:loading="usersTable.loading"
|
||||||
@@ -61,7 +61,6 @@ include "users/_createWalletDialog.html" %}
|
|||||||
icon="content_copy"
|
icon="content_copy"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="copyText(props.row.id)"
|
@click="copyText(props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy User ID</q-tooltip>
|
<q-tooltip>Copy User ID</q-tooltip>
|
||||||
@@ -71,8 +70,7 @@ include "users/_createWalletDialog.html" %}
|
|||||||
v-if="!props.row.is_super_user"
|
v-if="!props.row.is_super_user"
|
||||||
icon="build"
|
icon="build"
|
||||||
size="sm"
|
size="sm"
|
||||||
:color="props.row.is_admin ? 'primary' : 'grey'"
|
:color="props.row.is_admin ? 'primary' : ''"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="toggleAdmin(props.row.id)"
|
@click="toggleAdmin(props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Toggle Admin</q-tooltip>
|
<q-tooltip>Toggle Admin</q-tooltip>
|
||||||
@@ -83,25 +81,14 @@ include "users/_createWalletDialog.html" %}
|
|||||||
icon="build"
|
icon="build"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="positive"
|
color="positive"
|
||||||
class="q-ml-xs"
|
|
||||||
>
|
>
|
||||||
<q-tooltip>Super User</q-tooltip>
|
<q-tooltip>Super User</q-tooltip>
|
||||||
</q-btn>
|
</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
|
<q-btn
|
||||||
round
|
round
|
||||||
icon="delete"
|
icon="delete"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="negative"
|
color="negative"
|
||||||
class="q-ml-xs"
|
|
||||||
@click="deleteUser(props.row.id, props)"
|
@click="deleteUser(props.row.id, props)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Delete User</q-tooltip>
|
<q-tooltip>Delete User</q-tooltip>
|
||||||
@@ -115,10 +102,7 @@ include "users/_createWalletDialog.html" %}
|
|||||||
<q-td auto-width v-text="props.row.transaction_count"></q-td>
|
<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.username"></q-td>
|
||||||
<q-td auto-width v-text="props.row.email"></q-td>
|
<q-td auto-width v-text="props.row.email"></q-td>
|
||||||
<q-td
|
<q-td auto-width v-text="props.row.last_payment"></q-td>
|
||||||
auto-width
|
|
||||||
v-text="formatDate(props.row.last_payment)"
|
|
||||||
></q-td>
|
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
|||||||
+22
-46
@@ -2,8 +2,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from time import time
|
from typing import Dict, List
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,9 +12,8 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
)
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
ConversionData,
|
ConversionData,
|
||||||
@@ -38,61 +36,39 @@ from lnbits.utils.exchange_rates import (
|
|||||||
get_fiat_rate_satoshis,
|
get_fiat_rate_satoshis,
|
||||||
satoshis_amount_as_fiat,
|
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
|
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 = APIRouter(tags=["Core"])
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||||
async def health() -> dict:
|
async def health():
|
||||||
return {
|
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_router.get(
|
||||||
"/api/v1/wallets",
|
"/api/v1/wallets",
|
||||||
name="Wallets",
|
name="Wallets",
|
||||||
description="Get basic info for all of user's 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[Wallet]:
|
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
|
||||||
return user.wallets
|
return [BaseWallet(**w.dict()) for w in user.wallets]
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/api/v1/account")
|
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||||
user = await create_user_account(wallet_name=data.name)
|
if not settings.new_accounts_allowed:
|
||||||
return user.wallets[0]
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Account creation is disabled.",
|
||||||
|
)
|
||||||
|
account = await create_user_account(wallet_name=data.name)
|
||||||
|
return account.wallets[0]
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||||
@@ -120,7 +96,7 @@ async def api_lnurlscan(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# params is what will be returned to the client
|
# params is what will be returned to the client
|
||||||
params: dict = {"domain": domain}
|
params: Dict = {"domain": domain}
|
||||||
|
|
||||||
if "tag=login" in url:
|
if "tag=login" in url:
|
||||||
params.update(kind="auth")
|
params.update(kind="auth")
|
||||||
@@ -169,7 +145,7 @@ async def api_lnurlscan(
|
|||||||
|
|
||||||
# callback with k1 already in it
|
# callback with k1 already in it
|
||||||
parsed_callback: ParseResult = urlparse(data["callback"])
|
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||||
qs: dict = parse_qs(parsed_callback.query)
|
qs: Dict = parse_qs(parsed_callback.query)
|
||||||
qs["k1"] = data["k1"]
|
qs["k1"] = data["k1"]
|
||||||
|
|
||||||
# balanceCheck/balanceNotify
|
# balanceCheck/balanceNotify
|
||||||
@@ -226,13 +202,13 @@ async def api_perform_lnurlauth(
|
|||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/rate/{currency}")
|
@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)
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
return {"rate": rate}
|
return {"rate": rate}
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/currencies")
|
@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()
|
return allowed_currencies()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+139
-258
@@ -1,18 +1,19 @@
|
|||||||
import base64
|
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
|
||||||
from http import HTTPStatus
|
|
||||||
from time import time
|
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||||
from loguru import logger
|
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.core.services import create_user_account
|
||||||
from lnbits.decorators import access_token_payload, check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.helpers import (
|
from lnbits.helpers import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
decrypt_internal_message,
|
decrypt_internal_message,
|
||||||
@@ -21,30 +22,25 @@ from lnbits.helpers import (
|
|||||||
is_valid_username,
|
is_valid_username,
|
||||||
)
|
)
|
||||||
from lnbits.settings import AuthMethods, settings
|
from lnbits.settings import AuthMethods, settings
|
||||||
from lnbits.utils.nostr import normalize_public_key, verify_event
|
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
get_account,
|
get_account,
|
||||||
get_account_by_email,
|
get_account_by_email,
|
||||||
get_account_by_pubkey,
|
|
||||||
get_account_by_username,
|
|
||||||
get_account_by_username_or_email,
|
get_account_by_username_or_email,
|
||||||
get_user_from_account,
|
get_user,
|
||||||
update_account,
|
update_account,
|
||||||
|
update_user_password,
|
||||||
|
verify_user_password,
|
||||||
)
|
)
|
||||||
from ..models import (
|
from ..models import (
|
||||||
AccessTokenPayload,
|
|
||||||
Account,
|
|
||||||
CreateUser,
|
CreateUser,
|
||||||
LoginUsernamePassword,
|
LoginUsernamePassword,
|
||||||
LoginUsr,
|
LoginUsr,
|
||||||
ResetUserPassword,
|
|
||||||
UpdateSuperuserPassword,
|
UpdateSuperuserPassword,
|
||||||
UpdateUser,
|
UpdateUser,
|
||||||
UpdateUserPassword,
|
UpdateUserPassword,
|
||||||
UpdateUserPubkey,
|
|
||||||
User,
|
User,
|
||||||
UserExtra,
|
UserConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||||
@@ -59,43 +55,41 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
|||||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
HTTP_401_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)
|
||||||
|
|
||||||
@auth_router.post("/nostr", description="Login via Nostr")
|
if not user:
|
||||||
async def nostr_login(request: Request) -> JSONResponse:
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
if not await verify_user_password(user.id, data.password):
|
||||||
raise HTTPException(
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||||
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
|
|
||||||
)
|
return _auth_success_response(user.username, user.id)
|
||||||
event = _nostr_nip98_event(request)
|
except HTTPException as exc:
|
||||||
account = await get_account_by_pubkey(event["pubkey"])
|
raise exc
|
||||||
if not account:
|
except Exception as exc:
|
||||||
account = Account(
|
logger.debug(exc)
|
||||||
id=uuid4().hex,
|
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
|
||||||
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")
|
@auth_router.post("/usr", description="Login via the User ID")
|
||||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
raise HTTPException(
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
||||||
HTTPStatus.UNAUTHORIZED,
|
|
||||||
"Login by 'User ID' not allowed.",
|
try:
|
||||||
)
|
user = await get_user(data.usr)
|
||||||
account = await get_account(data.usr)
|
if not user:
|
||||||
if not account:
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
|
||||||
return _auth_success_response(account.username, account.id, account.email)
|
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
|
||||||
|
|
||||||
|
|
||||||
@auth_router.get("/{provider}", description="SSO Provider")
|
@auth_router.get("/{provider}", description="SSO Provider")
|
||||||
@@ -105,8 +99,7 @@ async def login_with_sso_provider(
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||||
f"Login by '{provider}' not allowed.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||||
@@ -120,22 +113,31 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||||
f"Login by '{provider}' not allowed.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with provider_sso:
|
try:
|
||||||
userinfo = await provider_sso.verify_and_process(request)
|
with provider_sso:
|
||||||
if not userinfo:
|
userinfo = await provider_sso.verify_and_process(request)
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
|
assert userinfo is not None
|
||||||
user_id = decrypt_internal_message(provider_sso.state)
|
user_id = decrypt_internal_message(provider_sso.state)
|
||||||
request.session.pop("user", None)
|
request.session.pop("user", None)
|
||||||
return await _handle_sso_login(userinfo, user_id)
|
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
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/logout")
|
@auth_router.post("/logout")
|
||||||
async def logout() -> JSONResponse:
|
async def logout() -> JSONResponse:
|
||||||
response = JSONResponse({"status": "success"}, HTTPStatus.OK)
|
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
||||||
response.delete_cookie("cookie_access_token")
|
response.delete_cookie("cookie_access_token")
|
||||||
response.delete_cookie("is_lnbits_user_authorized")
|
response.delete_cookie("is_lnbits_user_authorized")
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
@@ -148,124 +150,55 @@ async def logout() -> JSONResponse:
|
|||||||
async def register(data: CreateUser) -> JSONResponse:
|
async def register(data: CreateUser) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
|
||||||
"Register by 'Username and Password' not allowed.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.password != data.password_repeat:
|
if data.password != data.password_repeat:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
|
||||||
|
|
||||||
if not data.username:
|
if not data.username:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Missing username.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
|
||||||
if not is_valid_username(data.username):
|
if not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTP_400_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):
|
if data.email and not is_valid_email_address(data.email):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
account = Account(
|
try:
|
||||||
id=uuid4().hex,
|
user = await create_user_account(
|
||||||
email=data.email,
|
email=data.email, username=data.username, password=data.password
|
||||||
username=data.username,
|
)
|
||||||
)
|
return _auth_success_response(user.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:
|
||||||
@auth_router.put("/pubkey")
|
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||||
async def update_pubkey(
|
except Exception as exc:
|
||||||
data: UpdateUserPubkey,
|
logger.debug(exc)
|
||||||
user: User = Depends(check_user_exists),
|
raise HTTPException(
|
||||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
|
||||||
) -> Optional[User]:
|
) from exc
|
||||||
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")
|
@auth_router.put("/password")
|
||||||
async def update_password(
|
async def update_password(
|
||||||
data: UpdateUserPassword,
|
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
||||||
user: User = Depends(check_user_exists),
|
|
||||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
|
||||||
) -> Optional[User]:
|
) -> 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):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||||
)
|
)
|
||||||
|
if data.user_id != user.id:
|
||||||
assert data.password == data.password_repeat, "Passwords do not match."
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||||
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
return await update_user_password(data)
|
||||||
reset_data_json = decrypt_internal_message(reset_key)
|
except AssertionError as exc:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError("Invalid reset key.") from exc
|
logger.debug(exc)
|
||||||
|
raise HTTPException(
|
||||||
assert reset_data_json, "Cannot process reset key."
|
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
||||||
|
) from exc
|
||||||
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")
|
@auth_router.put("/update")
|
||||||
@@ -273,83 +206,80 @@ async def update(
|
|||||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||||
if data.username and not is_valid_username(data.username):
|
if data.username and not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
||||||
if data.email != user.email:
|
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(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
|
||||||
"Email mismatch.",
|
) from exc
|
||||||
)
|
|
||||||
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")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
||||||
account = await get_account(settings.super_user)
|
try:
|
||||||
if not account:
|
await update_account(
|
||||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
user_id=settings.super_user,
|
||||||
account.username = data.username
|
username=data.username,
|
||||||
account.extra = account.extra or UserExtra()
|
user_config=UserConfig(provider="lnbits"),
|
||||||
account.extra.provider = "lnbits"
|
)
|
||||||
account.hash_password(data.password)
|
super_user = UpdateUserPassword(
|
||||||
await update_account(account)
|
user_id=settings.super_user,
|
||||||
settings.first_install = False
|
password=data.password,
|
||||||
return _auth_success_response(account.username, account.id, account.email)
|
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
|
||||||
|
|
||||||
|
|
||||||
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
||||||
email = userinfo.email
|
email = userinfo.email
|
||||||
if not email or not is_valid_email_address(email):
|
if not email or not is_valid_email_address(email):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
redirect_path = "/wallet"
|
redirect_path = "/wallet"
|
||||||
|
user_config = UserConfig(**dict(userinfo))
|
||||||
|
user_config.email_verified = True
|
||||||
|
|
||||||
account = await get_account_by_email(email)
|
account = await get_account_by_email(email)
|
||||||
|
|
||||||
if verified_user_id:
|
if verified_user_id:
|
||||||
if account:
|
if account:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
|
||||||
account = await get_account(verified_user_id)
|
account = await get_account(verified_user_id)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
|
||||||
redirect_path = "/account"
|
redirect_path = "/account"
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
account.extra = account.extra or UserExtra()
|
user = await update_account(account.id, email=email, user_config=user_config)
|
||||||
account.extra.email_verified = True
|
|
||||||
await update_account(account)
|
|
||||||
else:
|
else:
|
||||||
account = Account(
|
if not settings.new_accounts_allowed:
|
||||||
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
|
||||||
)
|
user = await create_user_account(email=email, user_config=user_config)
|
||||||
await create_user_account(account)
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
|
||||||
|
|
||||||
return _auth_redirect_response(redirect_path, email)
|
return _auth_redirect_response(redirect_path, email)
|
||||||
|
|
||||||
|
|
||||||
@@ -358,10 +288,9 @@ def _auth_success_response(
|
|||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
email: Optional[str] = None,
|
email: Optional[str] = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
payload = AccessTokenPayload(
|
access_token = create_access_token(
|
||||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
data={"sub": username or "", "usr": user_id, "email": email}
|
||||||
)
|
)
|
||||||
access_token = create_access_token(data=payload.dict())
|
|
||||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
@@ -371,8 +300,7 @@ def _auth_success_response(
|
|||||||
|
|
||||||
|
|
||||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
||||||
access_token = create_access_token(data=payload.dict())
|
|
||||||
response = RedirectResponse(path)
|
response = RedirectResponse(path)
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
@@ -421,50 +349,3 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
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!",
|
|
||||||
)
|
|
||||||
|
|||||||
+196
-179
@@ -1,6 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import traceback
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import (
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
)
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -10,23 +13,10 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.extensions.extension_manager import (
|
from lnbits.core.db import core_app_extra
|
||||||
activate_extension,
|
from lnbits.core.helpers import (
|
||||||
deactivate_extension,
|
migrate_extension_database,
|
||||||
install_extension,
|
stop_extension_background_work,
|
||||||
uninstall_extension,
|
|
||||||
)
|
|
||||||
from lnbits.core.extensions.models import (
|
|
||||||
CreateExtension,
|
|
||||||
Extension,
|
|
||||||
ExtensionConfig,
|
|
||||||
ExtensionMeta,
|
|
||||||
ExtensionRelease,
|
|
||||||
InstallableExtension,
|
|
||||||
PayToEnableInfo,
|
|
||||||
ReleasePaymentInfo,
|
|
||||||
UserExtension,
|
|
||||||
UserExtensionInfo,
|
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
SimpleStatus,
|
SimpleStatus,
|
||||||
@@ -34,20 +24,38 @@ from lnbits.core.models import (
|
|||||||
)
|
)
|
||||||
from lnbits.core.services import check_transaction_status, create_invoice
|
from lnbits.core.services import check_transaction_status, create_invoice
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
|
check_access_token,
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
)
|
)
|
||||||
|
from lnbits.extension_manager import (
|
||||||
|
CreateExtension,
|
||||||
|
Extension,
|
||||||
|
ExtensionRelease,
|
||||||
|
InstallableExtension,
|
||||||
|
PayToEnableInfo,
|
||||||
|
ReleasePaymentInfo,
|
||||||
|
UserExtensionInfo,
|
||||||
|
fetch_github_release_config,
|
||||||
|
fetch_release_details,
|
||||||
|
fetch_release_payment_info,
|
||||||
|
get_valid_extensions,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_user_extension,
|
add_installed_extension,
|
||||||
delete_dbversion,
|
delete_dbversion,
|
||||||
|
delete_installed_extension,
|
||||||
drop_extension_db,
|
drop_extension_db,
|
||||||
get_db_version,
|
get_dbversions,
|
||||||
get_installed_extension,
|
get_installed_extension,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_user_extension,
|
get_user_extension,
|
||||||
update_installed_extension,
|
update_extension_pay_to_enable,
|
||||||
|
update_installed_extension_state,
|
||||||
update_user_extension,
|
update_user_extension,
|
||||||
|
update_user_extension_extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
extension_router = APIRouter(
|
extension_router = APIRouter(
|
||||||
@@ -56,8 +64,12 @@ extension_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@extension_router.post("", dependencies=[Depends(check_admin)])
|
@extension_router.post("")
|
||||||
async def api_install_extension(data: CreateExtension):
|
async def api_install_extension(
|
||||||
|
data: CreateExtension,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
access_token: Optional[str] = Depends(check_access_token),
|
||||||
|
):
|
||||||
release = await InstallableExtension.get_extension_release(
|
release = await InstallableExtension.get_extension_release(
|
||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
@@ -72,73 +84,78 @@ async def api_install_extension(data: CreateExtension):
|
|||||||
)
|
)
|
||||||
|
|
||||||
release.payment_hash = data.payment_hash
|
release.payment_hash = data.payment_hash
|
||||||
ext_meta = ExtensionMeta(installed_release=release)
|
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
id=data.ext_id,
|
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||||
name=data.ext_id,
|
|
||||||
version=data.version,
|
|
||||||
meta=ext_meta,
|
|
||||||
icon=release.icon,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extension = await install_extension(ext_info)
|
installed_ext = await get_installed_extension(data.ext_id)
|
||||||
|
ext_info.payments = installed_ext.payments if installed_ext else []
|
||||||
|
|
||||||
except Exception as exc:
|
await ext_info.download_archive()
|
||||||
logger.warning(exc)
|
|
||||||
etype, _, tb = sys.exc_info()
|
ext_info.extract_archive()
|
||||||
traceback.print_exception(etype, exc, tb)
|
|
||||||
ext_info.clean_extension_files()
|
extension = Extension.from_installable_ext(ext_info)
|
||||||
detail = (
|
|
||||||
str(exc)
|
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||||
if isinstance(exc, AssertionError)
|
await migrate_extension_database(extension, db_version)
|
||||||
else f"Failed to install extension '{ext_info.id}'."
|
|
||||||
f"({ext_info.installed_version})."
|
ext_info.active = True
|
||||||
)
|
await add_installed_extension(ext_info)
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
if extension.is_upgrade_extension:
|
||||||
detail=detail,
|
# call stop while the old routes are still active
|
||||||
) from exc
|
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||||
|
|
||||||
|
# mount routes for the new version
|
||||||
|
core_app_extra.register_new_ext_routes(extension)
|
||||||
|
|
||||||
|
ext_info.notify_upgrade(extension.upgrade_hash)
|
||||||
|
settings.lnbits_deactivated_extensions.discard(data.ext_id)
|
||||||
|
|
||||||
try:
|
|
||||||
await activate_extension(extension)
|
|
||||||
return extension
|
return extension
|
||||||
|
except AssertionError as exc:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
await deactivate_extension(extension.code)
|
ext_info.clean_extension_files()
|
||||||
detail = (
|
|
||||||
str(exc)
|
|
||||||
if isinstance(exc, AssertionError)
|
|
||||||
else f"Extension `{extension.code}` installed, but activation failed."
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=detail,
|
detail=(
|
||||||
|
f"Failed to install extension {ext_info.id} "
|
||||||
|
f"({ext_info.installed_version})."
|
||||||
|
),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.get("/{ext_id}/details")
|
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
|
||||||
async def api_extension_details(
|
async def api_extension_details(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
details_link: str,
|
details_link: str,
|
||||||
):
|
):
|
||||||
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
|
||||||
|
|
||||||
release = next((r for r in all_releases if r.details_link == details_link), None)
|
try:
|
||||||
if not release:
|
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
release_details = await ExtensionRelease.fetch_release_details(details_link)
|
release = next(
|
||||||
if not release_details:
|
(r for r in all_releases if r.details_link == details_link), None
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Cannot fetch details for release",
|
|
||||||
)
|
)
|
||||||
release_details["icon"] = release.icon
|
assert release, "Details not found for release"
|
||||||
release_details["repo"] = release.repo
|
|
||||||
return release_details
|
release_details = await 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)
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
f"Failed to get details for extension {ext_id}.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/sell")
|
@extension_router.put("/{ext_id}/sell")
|
||||||
@@ -147,28 +164,29 @@ async def api_update_pay_to_enable(
|
|||||||
data: PayToEnableInfo,
|
data: PayToEnableInfo,
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
if data.wallet not in user.wallet_ids:
|
try:
|
||||||
raise HTTPException(
|
assert (
|
||||||
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
|
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."
|
||||||
)
|
)
|
||||||
extension = await get_installed_extension(ext_id)
|
except AssertionError as exc:
|
||||||
if not extension:
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' not found.")
|
except Exception as exc:
|
||||||
if extension.meta:
|
logger.warning(exc)
|
||||||
extension.meta.pay_to_enable = data
|
raise HTTPException(
|
||||||
else:
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
extension.meta = ExtensionMeta(pay_to_enable=data)
|
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
|
||||||
await update_installed_extension(extension)
|
) from exc
|
||||||
return SimpleStatus(
|
|
||||||
success=True, message=f"Payment info updated for '{ext_id}' extension."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/enable")
|
@extension_router.put("/{ext_id}/enable")
|
||||||
async def api_enable_extension(
|
async def api_enable_extension(
|
||||||
ext_id: str, user: User = Depends(check_user_exists)
|
ext_id: str, user: User = Depends(check_user_exists)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
|
if ext_id not in [e.code for e in get_valid_extensions()]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||||
)
|
)
|
||||||
@@ -178,34 +196,28 @@ async def api_enable_extension(
|
|||||||
assert ext, f"Extension '{ext_id}' is not installed."
|
assert ext, f"Extension '{ext_id}' is not installed."
|
||||||
assert ext.active, f"Extension '{ext_id}' is not activated."
|
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:
|
if user.admin or not ext.requires_payment:
|
||||||
user_ext.active = True
|
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"Extension '{ext_id}' enabled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||||
|
|
||||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
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):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_ext.is_paid:
|
if user_ext.is_paid:
|
||||||
user_ext.active = True
|
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
||||||
await update_user_extension(user_ext)
|
|
||||||
return SimpleStatus(
|
return SimpleStatus(
|
||||||
success=True, message=f"Paid extension '{ext_id}' enabled."
|
success=True, message=f"Paid extension '{ext_id}' enabled."
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
|
ext.pay_to_enable and ext.pay_to_enable.wallet
|
||||||
), f"Extension '{ext_id}' is missing payment wallet."
|
), f"Extension '{ext_id}' is missing payment wallet."
|
||||||
|
|
||||||
payment_status = await check_transaction_status(
|
payment_status = await check_transaction_status(
|
||||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
wallet_id=ext.pay_to_enable.wallet,
|
||||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,9 +227,10 @@ async def api_enable_extension(
|
|||||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
user_ext.active = True
|
|
||||||
user_ext.extra.paid_to_enable = True
|
user_ext.extra.paid_to_enable = True
|
||||||
await update_user_extension(user_ext)
|
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)
|
||||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||||
|
|
||||||
except AssertionError as exc:
|
except AssertionError as exc:
|
||||||
@@ -236,19 +249,20 @@ async def api_enable_extension(
|
|||||||
async def api_disable_extension(
|
async def api_disable_extension(
|
||||||
ext_id: str, user: User = Depends(check_user_exists)
|
ext_id: str, user: User = Depends(check_user_exists)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
|
if ext_id not in [e.code for e in get_valid_extensions()]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
||||||
)
|
)
|
||||||
user_ext = await get_user_extension(user.id, ext_id)
|
try:
|
||||||
if not user_ext or not user_ext.active:
|
logger.info(f"Disabeling extension: {ext_id}.")
|
||||||
return SimpleStatus(
|
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
|
||||||
success=True, message=f"Extension '{ext_id}' already disabled."
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||||
)
|
except Exception as exc:
|
||||||
logger.info(f"Disabeling extension: {ext_id}.")
|
logger.warning(exc)
|
||||||
user_ext.active = False
|
raise HTTPException(
|
||||||
await update_user_extension(user_ext)
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
detail=(f"Failed to disable '{ext_id}'."),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||||
@@ -256,14 +270,20 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Activating extension: '{ext_id}'.")
|
logger.info(f"Activating extension: '{ext_id}'.")
|
||||||
|
|
||||||
ext = Extension.get_valid_extension(ext_id)
|
all_extensions = get_valid_extensions()
|
||||||
|
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
|
# if extension never loaded (was deactivated on server startup)
|
||||||
|
if ext_id not in sys.modules.keys():
|
||||||
|
# run extension start-up routine
|
||||||
|
core_app_extra.register_new_ext_routes(ext)
|
||||||
|
|
||||||
await activate_extension(ext)
|
settings.lnbits_deactivated_extensions.discard(ext_id)
|
||||||
|
|
||||||
|
await update_installed_extension_state(ext_id=ext_id, active=True)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
await deactivate_extension(ext_id)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=(f"Failed to activate '{ext_id}'."),
|
detail=(f"Failed to activate '{ext_id}'."),
|
||||||
@@ -275,10 +295,13 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||||
|
|
||||||
ext = Extension.get_valid_extension(ext_id)
|
all_extensions = get_valid_extensions()
|
||||||
|
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
|
|
||||||
await deactivate_extension(ext_id)
|
settings.lnbits_deactivated_extensions.add(ext_id)
|
||||||
|
|
||||||
|
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
@@ -288,27 +311,27 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
@extension_router.delete("/{ext_id}")
|
||||||
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
async def api_uninstall_extension(
|
||||||
|
ext_id: str,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
access_token: Optional[str] = Depends(check_access_token),
|
||||||
|
) -> SimpleStatus:
|
||||||
|
installed_extensions = await get_installed_extensions()
|
||||||
|
|
||||||
extension = await get_installed_extension(ext_id)
|
extensions = [e for e in installed_extensions if e.id == ext_id]
|
||||||
if not extension:
|
if len(extensions) == 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"Unknown extension id: {ext_id}",
|
detail=f"Unknown extension id: {ext_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
installed_extensions = await get_installed_extensions()
|
|
||||||
# check that other extensions do not depend on this one
|
# check that other extensions do not depend on this one
|
||||||
for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
|
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
|
||||||
installed_ext = next(
|
installed_ext = next(
|
||||||
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
||||||
)
|
)
|
||||||
if (
|
if installed_ext and ext_id in installed_ext.dependencies:
|
||||||
installed_ext
|
|
||||||
and installed_ext.meta
|
|
||||||
and ext_id in installed_ext.meta.dependencies
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -318,7 +341,14 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await uninstall_extension(ext_id)
|
# call stop while the old routes are still active
|
||||||
|
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||||
|
|
||||||
|
settings.lnbits_deactivated_extensions.add(ext_id)
|
||||||
|
|
||||||
|
for ext_info in extensions:
|
||||||
|
ext_info.clean_extension_files()
|
||||||
|
await delete_installed_extension(ext_id=ext_info.id)
|
||||||
|
|
||||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
||||||
@@ -329,9 +359,9 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
|||||||
|
|
||||||
|
|
||||||
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
@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:
|
try:
|
||||||
extension_releases: list[ExtensionRelease] = (
|
extension_releases: List[ExtensionRelease] = (
|
||||||
await InstallableExtension.get_extension_releases(ext_id)
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -367,8 +397,9 @@ async def get_pay_to_install_invoice(
|
|||||||
assert release, "Release not found."
|
assert release, "Release not found."
|
||||||
assert release.pay_link, "Pay link not found for release."
|
assert release.pay_link, "Pay link not found for release."
|
||||||
|
|
||||||
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
payment_info = await fetch_release_payment_info(
|
||||||
|
release.pay_link, data.cost_sats
|
||||||
|
)
|
||||||
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
||||||
invoice = bolt11_decode(payment_info.payment_request)
|
invoice = bolt11_decode(payment_info.payment_request)
|
||||||
|
|
||||||
@@ -396,59 +427,45 @@ async def get_pay_to_install_invoice(
|
|||||||
async def get_pay_to_enable_invoice(
|
async def get_pay_to_enable_invoice(
|
||||||
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
||||||
):
|
):
|
||||||
if not data.amount or data.amount <= 0:
|
try:
|
||||||
raise HTTPException(
|
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
|
|
||||||
|
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.",
|
||||||
)
|
)
|
||||||
|
|
||||||
ext = await get_installed_extension(ext_id)
|
user_ext = await get_user_extension(user.id, ext_id)
|
||||||
if not ext:
|
user_ext_info = (
|
||||||
raise HTTPException(
|
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail=f"Extension '{ext_id}' not found."
|
|
||||||
)
|
)
|
||||||
|
user_ext_info.payment_hash_to_enable = payment_hash
|
||||||
|
await update_user_extension_extra(user.id, ext_id, user_ext_info)
|
||||||
|
|
||||||
if not ext.meta or not ext.meta.pay_to_enable:
|
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)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
|
||||||
detail=f"Payment info not found for extension '{ext_id}'.",
|
) from exc
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
@extension_router.get(
|
||||||
@@ -457,7 +474,7 @@ async def get_pay_to_enable_invoice(
|
|||||||
)
|
)
|
||||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||||
try:
|
try:
|
||||||
config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
|
config = await fetch_github_release_config(org, repo, tag_name)
|
||||||
if not config:
|
if not config:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -478,7 +495,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
|||||||
)
|
)
|
||||||
async def delete_extension_db(ext_id: str):
|
async def delete_extension_db(ext_id: str):
|
||||||
try:
|
try:
|
||||||
db_version = await get_db_version(ext_id)
|
db_version = (await get_dbversions()).get(ext_id, None)
|
||||||
if not db_version:
|
if not db_version:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|||||||
@@ -9,24 +9,25 @@ from fastapi.exceptions import HTTPException
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
from lnurl import decode as lnurl_decode
|
from lnurl import decode as lnurl_decode
|
||||||
|
from loguru import logger
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension
|
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.core.services import create_invoice, create_user_account
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.decorators import check_admin, check_user_exists
|
from lnbits.decorators import check_admin, check_user_exists
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
|
|
||||||
|
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
create_account,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
get_db_versions,
|
get_dbversions,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_user,
|
get_user,
|
||||||
get_wallet,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
generic_router = APIRouter(
|
generic_router = APIRouter(
|
||||||
@@ -73,87 +74,83 @@ async def robots():
|
|||||||
|
|
||||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
try:
|
||||||
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 = await InstallableExtension.get_installable_extensions()
|
||||||
installable_exts_ids = [e.id for e in installable_exts]
|
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 += [
|
||||||
|
e for e in installed_exts if e.id not in installable_exts_ids
|
||||||
|
]
|
||||||
|
|
||||||
for e in installable_exts:
|
for e in installable_exts:
|
||||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||||
if installed_ext and installed_ext.meta:
|
if installed_ext:
|
||||||
installed_release = installed_ext.meta.installed_release
|
e.installed_release = installed_ext.installed_release
|
||||||
if installed_ext.meta.pay_to_enable and not user.admin:
|
if installed_ext.pay_to_enable and not user.admin:
|
||||||
# not a security leak, but better not to share the wallet id
|
# not a security leak, but better not to share the wallet id
|
||||||
installed_ext.meta.pay_to_enable.wallet = None
|
installed_ext.pay_to_enable.wallet = None
|
||||||
pay_to_enable = installed_ext.meta.pay_to_enable
|
e.pay_to_enable = installed_ext.pay_to_enable
|
||||||
|
|
||||||
if e.meta:
|
# use the installed extension values
|
||||||
e.meta.installed_release = installed_release
|
e.name = installed_ext.name
|
||||||
e.meta.pay_to_enable = pay_to_enable
|
e.short_description = installed_ext.short_description
|
||||||
else:
|
e.icon = installed_ext.icon
|
||||||
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
|
|
||||||
|
|
||||||
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
|
except Exception as ex:
|
||||||
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
|
logger.warning(ex)
|
||||||
db_versions = await get_db_versions()
|
installable_exts = []
|
||||||
|
installed_exts_ids = []
|
||||||
|
|
||||||
extensions = [
|
try:
|
||||||
{
|
all_ext_ids = [ext.code for ext in get_valid_extensions()]
|
||||||
"id": ext.id,
|
inactive_extensions = [
|
||||||
"name": ext.name,
|
e.id for e in await get_installed_extensions(active=False)
|
||||||
"icon": ext.icon,
|
]
|
||||||
"shortDescription": ext.short_description,
|
db_version = await get_dbversions()
|
||||||
"stars": ext.stars,
|
extensions = [
|
||||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
{
|
||||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
"id": ext.id,
|
||||||
"isInstalled": ext.id in installed_exts_ids,
|
"name": ext.name,
|
||||||
"hasDatabaseTables": next(
|
"icon": ext.icon,
|
||||||
(True for version in db_versions if version.db == ext.id), False
|
"shortDescription": ext.short_description,
|
||||||
),
|
"stars": ext.stars,
|
||||||
"isAvailable": ext.id in all_ext_ids,
|
"isFeatured": ext.featured,
|
||||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
"dependencies": ext.dependencies,
|
||||||
"isActive": ext.id not in inactive_extensions,
|
"isInstalled": ext.id in installed_exts_ids,
|
||||||
"latestRelease": (
|
"hasDatabaseTables": ext.id in db_version,
|
||||||
dict(ext.meta.latest_release)
|
"isAvailable": ext.id in all_ext_ids,
|
||||||
if ext.meta and ext.meta.latest_release
|
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||||
else None
|
"isActive": ext.id not in inactive_extensions,
|
||||||
),
|
"latestRelease": (
|
||||||
"installedRelease": (
|
dict(ext.latest_release) if ext.latest_release else None
|
||||||
dict(ext.meta.installed_release)
|
),
|
||||||
if ext.meta and ext.meta.installed_release
|
"installedRelease": (
|
||||||
else None
|
dict(ext.installed_release) if ext.installed_release else None
|
||||||
),
|
),
|
||||||
"payToEnable": (
|
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
|
||||||
dict(ext.meta.pay_to_enable)
|
"isPaymentRequired": ext.requires_payment,
|
||||||
if ext.meta and ext.meta.pay_to_enable
|
}
|
||||||
else {}
|
for ext in installable_exts
|
||||||
),
|
]
|
||||||
"isPaymentRequired": ext.requires_payment,
|
|
||||||
}
|
|
||||||
for ext in installable_exts
|
|
||||||
]
|
|
||||||
|
|
||||||
# refresh user state. Eg: enabled extensions.
|
# refresh user state. Eg: enabled extensions.
|
||||||
# TODO: refactor
|
user = await get_user(user.id) or user
|
||||||
# user = await get_user(user.id) or user
|
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"core/extensions.html",
|
"core/extensions.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
"extensions": extensions,
|
"extensions": extensions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
@@ -168,16 +165,18 @@ async def wallet(
|
|||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None),
|
||||||
):
|
):
|
||||||
if wal:
|
if wal:
|
||||||
wallet = await get_wallet(wal.hex)
|
wallet_id = wal.hex
|
||||||
elif len(user.wallets) == 0:
|
elif len(user.wallets) == 0:
|
||||||
wallet = await create_wallet(user_id=user.id)
|
wallet = await create_wallet(user_id=user.id)
|
||||||
user.wallets.append(wallet)
|
user = await get_user(user_id=user.id) or user
|
||||||
|
wallet_id = wallet.id
|
||||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||||
wallet = await get_wallet(lnbits_last_active_wallet)
|
wallet_id = lnbits_last_active_wallet
|
||||||
else:
|
else:
|
||||||
wallet = user.wallets[0]
|
wallet_id = user.wallets[0].id
|
||||||
|
|
||||||
if not wallet or wallet.deleted:
|
user_wallet = user.get_wallet(wallet_id)
|
||||||
|
if not user_wallet or user_wallet.deleted:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
@@ -186,16 +185,15 @@ async def wallet(
|
|||||||
request,
|
request,
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
"wallet": wallet.json(),
|
"wallet": user_wallet.dict(),
|
||||||
"wallet_name": wallet.name,
|
|
||||||
"currencies": allowed_currencies(),
|
"currencies": allowed_currencies(),
|
||||||
"service_fee": settings.lnbits_service_fee,
|
"service_fee": settings.lnbits_service_fee,
|
||||||
"service_fee_max": settings.lnbits_service_fee_max,
|
"service_fee_max": settings.lnbits_service_fee_max,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
|
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +210,7 @@ async def account(
|
|||||||
request,
|
request,
|
||||||
"core/account.html",
|
"core/account.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,9 +230,11 @@ async def service_worker(request: Request):
|
|||||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||||
async def manifest(request: Request, usr: str):
|
async def manifest(request: Request, usr: str):
|
||||||
host = urlparse(str(request.url)).netloc
|
host = urlparse(str(request.url)).netloc
|
||||||
|
|
||||||
user = await get_user(usr)
|
user = await get_user(usr)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"short_name": settings.lnbits_site_title,
|
"short_name": settings.lnbits_site_title,
|
||||||
"name": settings.lnbits_site_title + " Wallet",
|
"name": settings.lnbits_site_title + " Wallet",
|
||||||
@@ -322,10 +322,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
|||||||
request,
|
request,
|
||||||
"node/index.html",
|
"node/index.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"wallets": user.wallets[0].json(),
|
"wallets": user.wallets[0].dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
request,
|
request,
|
||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
@@ -377,7 +377,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
"users/index.html",
|
"users/index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.json(),
|
"user": user.dict(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
},
|
},
|
||||||
@@ -426,7 +426,7 @@ async def lnurlwallet(request: Request):
|
|||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid lnurl. Expected maxWithdrawable",
|
detail="Invalid lnurl. Expected maxWithdrawable",
|
||||||
)
|
)
|
||||||
account = await create_user_account()
|
account = await create_account()
|
||||||
wallet = await create_wallet(user_id=account.id)
|
wallet = await create_wallet(user_id=account.id)
|
||||||
_, payment_request = await create_invoice(
|
_, payment_request = await create_invoice(
|
||||||
wallet_id=wallet.id,
|
wallet_id=wallet.id,
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
Header,
|
Header,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
@@ -20,6 +21,7 @@ from loguru import logger
|
|||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
@@ -33,6 +35,7 @@ from lnbits.core.models import (
|
|||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
@@ -70,12 +73,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments(
|
async def api_payments(
|
||||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(key_info.wallet.id)
|
await update_pending_payments(wallet.wallet.id)
|
||||||
return await get_payments(
|
return await get_payments(
|
||||||
wallet_id=key_info.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
complete=True,
|
complete=True,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -89,12 +92,12 @@ async def api_payments(
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_history(
|
async def api_payments_history(
|
||||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
group: DateTrunc = Query("day"),
|
group: DateTrunc = Query("day"),
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(key_info.wallet.id)
|
await update_pending_payments(wallet.wallet.id)
|
||||||
return await get_payments_history(key_info.wallet.id, group, filters)
|
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
@@ -106,12 +109,12 @@ async def api_payments_history(
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_paginated(
|
async def api_payments_paginated(
|
||||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(key_info.wallet.id)
|
await update_pending_payments(wallet.wallet.id)
|
||||||
page = await get_payments_paginated(
|
page = await get_payments_paginated(
|
||||||
wallet_id=key_info.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
complete=True,
|
complete=True,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -119,7 +122,7 @@ async def api_payments_paginated(
|
|||||||
return page
|
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""
|
description_hash = b""
|
||||||
unhashed_description = b""
|
unhashed_description = b""
|
||||||
memo = data.memo or settings.lnbits_site_title
|
memo = data.memo or settings.lnbits_site_title
|
||||||
@@ -143,42 +146,60 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
# do not save memo if description_hash or unhashed_description is set
|
# do not save memo if description_hash or unhashed_description is set
|
||||||
memo = ""
|
memo = ""
|
||||||
|
|
||||||
payment = await create_invoice(
|
async with db.connect() as conn:
|
||||||
wallet_id=wallet.id,
|
payment_hash, payment_request = await create_invoice(
|
||||||
amount=data.amount,
|
wallet_id=wallet.id,
|
||||||
memo=memo,
|
amount=data.amount,
|
||||||
currency=data.unit,
|
memo=memo,
|
||||||
description_hash=description_hash,
|
currency=data.unit,
|
||||||
unhashed_description=unhashed_description,
|
description_hash=description_hash,
|
||||||
expiry=data.expiry,
|
unhashed_description=unhashed_description,
|
||||||
extra=data.extra,
|
expiry=data.expiry,
|
||||||
webhook=data.webhook,
|
extra=data.extra,
|
||||||
internal=data.internal,
|
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
|
||||||
|
|
||||||
# lnurl_response is not saved in the database
|
invoice = bolt11.decode(payment_request)
|
||||||
|
|
||||||
|
lnurl_response: Union[None, bool, str] = None
|
||||||
if data.lnurl_callback:
|
if data.lnurl_callback:
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
try:
|
try:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
data.lnurl_callback,
|
data.lnurl_callback,
|
||||||
params={"pr": payment.bolt11},
|
params={
|
||||||
|
"pr": payment_request,
|
||||||
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
payment.extra["lnurl_response"] = r.text
|
lnurl_response = r.text
|
||||||
else:
|
else:
|
||||||
resp = json.loads(r.text)
|
resp = json.loads(r.text)
|
||||||
if resp["status"] != "OK":
|
if resp["status"] != "OK":
|
||||||
payment.extra["lnurl_response"] = resp["reason"]
|
lnurl_response = resp["reason"]
|
||||||
else:
|
else:
|
||||||
payment.extra["lnurl_response"] = True
|
lnurl_response = True
|
||||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||||
logger.error(ex)
|
logger.error(ex)
|
||||||
payment.extra["lnurl_response"] = False
|
lnurl_response = False
|
||||||
|
|
||||||
return payment
|
return {
|
||||||
|
"payment_hash": invoice.payment_hash,
|
||||||
|
"payment_request": payment_request,
|
||||||
|
"lnurl_response": lnurl_response,
|
||||||
|
# maintain backwards compatibility with API clients:
|
||||||
|
"checking_id": checking_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post(
|
@payment_router.post(
|
||||||
@@ -200,25 +221,30 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def api_payments_create(
|
async def api_payments_create(
|
||||||
invoice_data: CreateInvoice,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Payment:
|
invoice_data: CreateInvoice = Body(...),
|
||||||
|
):
|
||||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||||
if not invoice_data.bolt11:
|
if not invoice_data.bolt11:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Missing BOLT11 invoice",
|
detail="BOLT11 string is invalid or not given",
|
||||||
)
|
)
|
||||||
payment = await pay_invoice(
|
|
||||||
|
payment_hash = await pay_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=invoice_data.bolt11,
|
payment_request=invoice_data.bolt11,
|
||||||
extra=invoice_data.extra,
|
extra=invoice_data.extra,
|
||||||
)
|
)
|
||||||
return payment
|
return {
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
# maintain backwards compatibility with API clients:
|
||||||
|
"checking_id": payment_hash,
|
||||||
|
}
|
||||||
|
|
||||||
elif not invoice_data.out:
|
elif not invoice_data.out:
|
||||||
# invoice key
|
# invoice key
|
||||||
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
return await api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
@@ -244,7 +270,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
|||||||
@payment_router.post("/lnurl")
|
@payment_router.post("/lnurl")
|
||||||
async def api_payments_pay_lnurl(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Payment:
|
):
|
||||||
domain = urlparse(data.callback).netloc
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
@@ -288,12 +314,15 @@ async def api_payments_pay_lnurl(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=(
|
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 = {}
|
extra = {}
|
||||||
|
|
||||||
if params.get("successAction"):
|
if params.get("successAction"):
|
||||||
extra["success_action"] = params["successAction"]
|
extra["success_action"] = params["successAction"]
|
||||||
if data.comment:
|
if data.comment:
|
||||||
@@ -302,14 +331,19 @@ async def api_payments_pay_lnurl(
|
|||||||
extra["fiat_currency"] = data.unit
|
extra["fiat_currency"] = data.unit
|
||||||
extra["fiat_amount"] = data.amount / 1000
|
extra["fiat_amount"] = data.amount / 1000
|
||||||
assert data.description is not None, "description is required"
|
assert data.description is not None, "description is required"
|
||||||
|
payment_hash = await pay_invoice(
|
||||||
payment = await pay_invoice(
|
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=params["pr"],
|
payment_request=params["pr"],
|
||||||
description=data.description,
|
description=data.description,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
return payment
|
|
||||||
|
return {
|
||||||
|
"success_action": params.get("successAction"),
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
# maintain backwards compatibility with API clients:
|
||||||
|
"checking_id": payment_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||||
@@ -344,10 +378,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||||||
|
|
||||||
@payment_router.get("/sse")
|
@payment_router.get("/sse")
|
||||||
async def api_payments_sse(
|
async def api_payments_sse(
|
||||||
request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
return EventSourceResponse(
|
return EventSourceResponse(
|
||||||
subscribe_wallet_invoices(request, key_info.wallet),
|
subscribe_wallet_invoices(request, wallet.wallet),
|
||||||
ping=20,
|
ping=20,
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import base64
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
delete_account,
|
delete_account,
|
||||||
@@ -17,8 +14,8 @@ from lnbits.core.crud import (
|
|||||||
update_admin_settings,
|
update_admin_settings,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
|
Account,
|
||||||
AccountFilters,
|
AccountFilters,
|
||||||
AccountOverview,
|
|
||||||
CreateTopup,
|
CreateTopup,
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -26,7 +23,7 @@ from lnbits.core.models import (
|
|||||||
from lnbits.core.services import update_wallet_balance
|
from lnbits.core.services import update_wallet_balance
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||||
from lnbits.helpers import encrypt_internal_message, generate_filter_params_openapi
|
from lnbits.helpers import generate_filter_params_openapi
|
||||||
from lnbits.settings import EditableSettings, settings
|
from lnbits.settings import EditableSettings, settings
|
||||||
|
|
||||||
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
|
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
|
||||||
@@ -40,102 +37,106 @@ users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_adm
|
|||||||
)
|
)
|
||||||
async def api_get_users(
|
async def api_get_users(
|
||||||
filters: Filters = Depends(parse_filters(AccountFilters)),
|
filters: Filters = Depends(parse_filters(AccountFilters)),
|
||||||
) -> Page[AccountOverview]:
|
) -> Page[Account]:
|
||||||
return await get_accounts(filters=filters)
|
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
|
||||||
|
|
||||||
|
|
||||||
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
|
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, user: User = Depends(check_admin)
|
user_id: str, user: User = Depends(check_admin)
|
||||||
) -> None:
|
) -> None:
|
||||||
wallets = await get_wallets(user_id)
|
|
||||||
if len(wallets) > 0:
|
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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Cannot delete user with wallets.",
|
detail=f"{exc!s}",
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
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)])
|
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
|
||||||
async def api_users_toggle_admin(user_id: str) -> None:
|
async def api_users_toggle_admin(user_id: str) -> None:
|
||||||
if user_id == settings.super_user:
|
try:
|
||||||
raise HTTPException(
|
if user_id == settings.super_user:
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
raise Exception("Cannot change super user.")
|
||||||
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
|
||||||
)
|
)
|
||||||
if user_id in settings.lnbits_admin_users:
|
await update_admin_settings(update_settings)
|
||||||
settings.lnbits_admin_users.remove(user_id)
|
except Exception as exc:
|
||||||
else:
|
raise HTTPException(
|
||||||
settings.lnbits_admin_users.append(user_id)
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
|
detail=f"Could not update admin settings. {exc}",
|
||||||
await update_admin_settings(update_settings)
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@users_router.get("/user/{user_id}/wallet")
|
@users_router.get("/user/{user_id}/wallet")
|
||||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
||||||
return await get_wallets(user_id)
|
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
|
||||||
|
|
||||||
|
|
||||||
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
|
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
|
||||||
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
|
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
|
||||||
wal = await get_wallet(wallet)
|
try:
|
||||||
if not wal:
|
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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Wallet does not exist.",
|
detail=f"{exc!s}",
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
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}")
|
@users_router.delete("/user/{user_id}/wallet/{wallet}")
|
||||||
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
|
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
|
||||||
wal = await get_wallet(wallet)
|
try:
|
||||||
if not wal:
|
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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail="Wallet does not exist.",
|
detail=f"{exc!s}",
|
||||||
)
|
) from exc
|
||||||
if wal.deleted:
|
|
||||||
await force_delete_wallet(wallet)
|
|
||||||
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
|
||||||
|
|
||||||
|
|
||||||
@users_router.put(
|
@users_router.put(
|
||||||
@@ -145,9 +146,14 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
|
|||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
)
|
)
|
||||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||||
await get_wallet(data.id)
|
try:
|
||||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
await get_wallet(data.id)
|
||||||
raise Exception("VoidWallet active")
|
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||||
|
raise Exception("VoidWallet active")
|
||||||
|
|
||||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from http import HTTPStatus
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Body,
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
@@ -15,14 +13,13 @@ from lnbits.core.models import (
|
|||||||
)
|
)
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
get_wallet,
|
|
||||||
update_wallet,
|
update_wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,45 +27,36 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
|
|||||||
|
|
||||||
|
|
||||||
@wallet_router.get("")
|
@wallet_router.get("")
|
||||||
async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
res = {
|
if wallet.key_type == KeyType.admin:
|
||||||
"name": key_info.wallet.name,
|
return {
|
||||||
"balance": key_info.wallet.balance_msat,
|
"id": wallet.wallet.id,
|
||||||
}
|
"name": wallet.wallet.name,
|
||||||
if key_info.key_type == KeyType.admin:
|
"balance": wallet.wallet.balance_msat,
|
||||||
res["id"] = key_info.wallet.id
|
}
|
||||||
return res
|
else:
|
||||||
|
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.put("/{new_name}")
|
@wallet_router.put("/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
await update_wallet(wallet.wallet.id, new_name)
|
||||||
if not wallet:
|
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
|
||||||
wallet.name = new_name
|
|
||||||
await update_wallet(wallet)
|
|
||||||
return {
|
return {
|
||||||
"id": wallet.id,
|
"id": wallet.wallet.id,
|
||||||
"name": wallet.name,
|
"name": wallet.wallet.name,
|
||||||
"balance": wallet.balance_msat,
|
"balance": wallet.wallet.balance_msat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.patch("")
|
@wallet_router.patch("", response_model=Wallet)
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
name: Optional[str] = Body(None),
|
name: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
):
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
return await update_wallet(wallet.wallet.id, name, currency)
|
||||||
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("")
|
@wallet_router.delete("")
|
||||||
@@ -81,9 +69,9 @@ async def api_delete_wallet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.post("")
|
@wallet_router.post("", response_model=Wallet)
|
||||||
async def api_create_wallet(
|
async def api_create_wallet(
|
||||||
data: CreateWallet,
|
data: CreateWallet,
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
|
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||||
|
|||||||
+128
-277
@@ -2,19 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Generic, Literal, Optional, TypeVar, Union
|
from sqlite3 import Row
|
||||||
|
from typing import Any, Generic, Literal, Optional, TypeVar
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel, ValidationError, root_validator
|
from pydantic import BaseModel, ValidationError, root_validator
|
||||||
from sqlalchemy import event
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
from sqlalchemy_aio.base import AsyncConnection
|
||||||
from sqlalchemy.sql import text
|
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -24,15 +24,31 @@ SQLITE = "SQLITE"
|
|||||||
|
|
||||||
if settings.lnbits_database_url:
|
if settings.lnbits_database_url:
|
||||||
database_uri = settings.lnbits_database_url
|
database_uri = settings.lnbits_database_url
|
||||||
|
|
||||||
if database_uri.startswith("cockroachdb://"):
|
if database_uri.startswith("cockroachdb://"):
|
||||||
DB_TYPE = COCKROACH
|
DB_TYPE = COCKROACH
|
||||||
else:
|
else:
|
||||||
if not database_uri.startswith("postgres://"):
|
|
||||||
raise ValueError(
|
|
||||||
"Please use the 'postgres://...' " "format for the database URL."
|
|
||||||
)
|
|
||||||
DB_TYPE = POSTGRES
|
DB_TYPE = POSTGRES
|
||||||
|
|
||||||
|
from psycopg2.extensions import DECIMAL, new_type, register_type
|
||||||
|
|
||||||
|
def _parse_timestamp(value, _):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||||
|
if "." not in value:
|
||||||
|
f = "%Y-%m-%d %H:%M:%S"
|
||||||
|
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
||||||
|
|
||||||
|
register_type(
|
||||||
|
new_type(
|
||||||
|
DECIMAL.values,
|
||||||
|
"DEC2FLOAT",
|
||||||
|
lambda value, curs: float(value) if value is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||||
else:
|
else:
|
||||||
if not os.path.isdir(settings.lnbits_data_folder):
|
if not os.path.isdir(settings.lnbits_data_folder):
|
||||||
os.mkdir(settings.lnbits_data_folder)
|
os.mkdir(settings.lnbits_data_folder)
|
||||||
@@ -40,21 +56,21 @@ else:
|
|||||||
DB_TYPE = SQLITE
|
DB_TYPE = SQLITE
|
||||||
|
|
||||||
|
|
||||||
def compat_timestamp_placeholder(key: str):
|
def compat_timestamp_placeholder():
|
||||||
if DB_TYPE == POSTGRES:
|
if DB_TYPE == POSTGRES:
|
||||||
return f"to_timestamp(:{key})"
|
return "to_timestamp(?)"
|
||||||
elif DB_TYPE == COCKROACH:
|
elif DB_TYPE == COCKROACH:
|
||||||
return f"cast(:{key} AS timestamp)"
|
return "cast(? AS timestamp)"
|
||||||
else:
|
else:
|
||||||
return f":{key}"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
def get_placeholder(model: Any, field: str) -> str:
|
def get_placeholder(model: Any, field: str) -> str:
|
||||||
type_ = model.__fields__[field].type_
|
type_ = model.__fields__[field].type_
|
||||||
if type_ == datetime.datetime:
|
if type_ == datetime.datetime:
|
||||||
return compat_timestamp_placeholder(field)
|
return compat_timestamp_placeholder()
|
||||||
else:
|
else:
|
||||||
return f":{field}"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
class Compat:
|
class Compat:
|
||||||
@@ -111,13 +127,15 @@ class Compat:
|
|||||||
return "BIGINT"
|
return "BIGINT"
|
||||||
return "INT"
|
return "INT"
|
||||||
|
|
||||||
def timestamp_placeholder(self, key: str) -> str:
|
@property
|
||||||
return compat_timestamp_placeholder(key)
|
def timestamp_placeholder(self) -> str:
|
||||||
|
return compat_timestamp_placeholder()
|
||||||
|
|
||||||
|
|
||||||
class Connection(Compat):
|
class Connection(Compat):
|
||||||
def __init__(self, conn: AsyncConnection, typ, name, schema):
|
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
|
self.txn = txn
|
||||||
self.type = typ
|
self.type = typ
|
||||||
self.name = name
|
self.name = name
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
@@ -128,76 +146,49 @@ class Connection(Compat):
|
|||||||
query = query.replace("?", "%s")
|
query = query.replace("?", "%s")
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def rewrite_values(self, values: dict) -> dict:
|
def rewrite_values(self, values):
|
||||||
# strip html
|
# strip html
|
||||||
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||||
clean_values: dict = {}
|
|
||||||
for key, raw_value in values.items():
|
# tuple to list and back to tuple
|
||||||
|
raw_values = [values] if isinstance(values, str) else list(values)
|
||||||
|
values = []
|
||||||
|
for raw_value in raw_values:
|
||||||
if isinstance(raw_value, str):
|
if isinstance(raw_value, str):
|
||||||
clean_values[key] = re.sub(clean_regex, "", raw_value)
|
values.append(re.sub(clean_regex, "", raw_value))
|
||||||
elif isinstance(raw_value, datetime.datetime):
|
elif isinstance(raw_value, datetime.datetime):
|
||||||
ts = raw_value.timestamp()
|
ts = raw_value.timestamp()
|
||||||
if self.type == SQLITE:
|
if self.type == SQLITE:
|
||||||
clean_values[key] = int(ts)
|
values.append(int(ts))
|
||||||
else:
|
else:
|
||||||
clean_values[key] = ts
|
values.append(ts)
|
||||||
else:
|
else:
|
||||||
clean_values[key] = raw_value
|
values.append(raw_value)
|
||||||
return clean_values
|
return tuple(values)
|
||||||
|
|
||||||
async def fetchall(
|
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||||
self,
|
result = await self.conn.execute(
|
||||||
query: str,
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
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,
|
|
||||||
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()
|
return await result.fetchall()
|
||||||
|
|
||||||
async def insert(self, table_name: str, model: BaseModel):
|
async def fetchone(self, query: str, values: tuple = ()):
|
||||||
await self.conn.execute(
|
result = await self.conn.execute(
|
||||||
text(insert_query(table_name, model)), model_to_dict(model)
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
)
|
)
|
||||||
await self.conn.commit()
|
row = await result.fetchone()
|
||||||
|
await result.close()
|
||||||
|
return row
|
||||||
|
|
||||||
async def fetch_page(
|
async def fetch_page(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
where: Optional[list[str]] = None,
|
where: Optional[list[str]] = None,
|
||||||
values: Optional[dict] = None,
|
values: Optional[list[str]] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[type[TModel]] = None,
|
model: Optional[type[TRowModel]] = None,
|
||||||
group_by: Optional[list[str]] = None,
|
group_by: Optional[list[str]] = None,
|
||||||
) -> Page[TModel]:
|
) -> Page[TRowModel]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where(where)
|
clause = filters.where(where)
|
||||||
@@ -220,15 +211,14 @@ class Connection(Compat):
|
|||||||
{filters.order_by()}
|
{filters.order_by()}
|
||||||
{filters.pagination()}
|
{filters.pagination()}
|
||||||
""",
|
""",
|
||||||
self.rewrite_values(parsed_values),
|
parsed_values,
|
||||||
model,
|
|
||||||
)
|
)
|
||||||
if rows:
|
if rows:
|
||||||
# no need for extra query if no pagination is specified
|
# no need for extra query if no pagination is specified
|
||||||
if filters.offset or filters.limit:
|
if filters.offset or filters.limit:
|
||||||
result = await self.execute(
|
count = await self.fetchone(
|
||||||
f"""
|
f"""
|
||||||
SELECT COUNT(*) as count FROM (
|
SELECT COUNT(*) FROM (
|
||||||
{query}
|
{query}
|
||||||
{clause}
|
{clause}
|
||||||
{group_by_string}
|
{group_by_string}
|
||||||
@@ -236,24 +226,21 @@ class Connection(Compat):
|
|||||||
""",
|
""",
|
||||||
parsed_values,
|
parsed_values,
|
||||||
)
|
)
|
||||||
row = result.mappings().first()
|
count = int(count[0])
|
||||||
result.close()
|
|
||||||
count = int(row.get("count", 0))
|
|
||||||
else:
|
else:
|
||||||
count = len(rows)
|
count = len(rows)
|
||||||
else:
|
else:
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
return Page(
|
return Page(
|
||||||
data=rows,
|
data=[model.from_row(row) for row in rows] if model else rows,
|
||||||
total=count,
|
total=count,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, query: str, values: Optional[dict] = None):
|
async def execute(self, query: str, values: tuple = ()):
|
||||||
params = self.rewrite_values(values) if values else {}
|
return await self.conn.execute(
|
||||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
await self.conn.commit()
|
)
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class Database(Compat):
|
class Database(Compat):
|
||||||
@@ -266,42 +253,18 @@ class Database(Compat):
|
|||||||
self.path = os.path.join(
|
self.path = os.path.join(
|
||||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||||
)
|
)
|
||||||
database_uri = f"sqlite+aiosqlite:///{self.path}"
|
database_uri = f"sqlite:///{self.path}"
|
||||||
else:
|
else:
|
||||||
database_uri = settings.lnbits_database_url.replace(
|
database_uri = settings.lnbits_database_url
|
||||||
"postgres://", "postgresql+asyncpg://"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.name.startswith("ext_"):
|
if self.name.startswith("ext_"):
|
||||||
self.schema = self.name[4:]
|
self.schema = self.name[4:]
|
||||||
else:
|
else:
|
||||||
self.schema = None
|
self.schema = None
|
||||||
|
|
||||||
self.engine: AsyncEngine = create_async_engine(
|
self.engine = create_engine(
|
||||||
database_uri, echo=settings.debug_database
|
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
|
|
||||||
@event.listens_for(self.engine.sync_engine, "connect")
|
|
||||||
def register_custom_types(dbapi_connection, *_):
|
|
||||||
def _parse_date(value) -> datetime.datetime:
|
|
||||||
if value is 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 datetime.datetime.strptime(value, f)
|
|
||||||
|
|
||||||
dbapi_connection.run_async(
|
|
||||||
lambda connection: connection.set_type_codec(
|
|
||||||
"TIMESTAMP",
|
|
||||||
encoder=datetime.datetime,
|
|
||||||
decoder=_parse_date,
|
|
||||||
schema="pg_catalog",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.trace(f"database {self.type} added for {self.name}")
|
logger.trace(f"database {self.type} added for {self.name}")
|
||||||
@@ -310,65 +273,49 @@ class Database(Compat):
|
|||||||
async def connect(self):
|
async def connect(self):
|
||||||
await self.lock.acquire()
|
await self.lock.acquire()
|
||||||
try:
|
try:
|
||||||
async with self.engine.connect() as conn:
|
async with self.engine.connect() as conn: # type: ignore
|
||||||
if not conn:
|
async with conn.begin() as txn:
|
||||||
raise Exception("Could not connect to the database")
|
wconn = Connection(conn, txn, self.type, self.name, self.schema)
|
||||||
|
|
||||||
wconn = Connection(conn, self.type, self.name, self.schema)
|
if self.schema:
|
||||||
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
await wconn.execute(
|
||||||
|
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
||||||
|
)
|
||||||
|
elif self.type == SQLITE:
|
||||||
|
await wconn.execute(
|
||||||
|
f"ATTACH '{self.path}' AS {self.schema}"
|
||||||
|
)
|
||||||
|
|
||||||
if self.schema:
|
yield wconn
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
await wconn.execute(
|
|
||||||
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
|
||||||
)
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
|
|
||||||
|
|
||||||
yield wconn
|
|
||||||
finally:
|
finally:
|
||||||
self.lock.release()
|
self.lock.release()
|
||||||
|
|
||||||
async def fetchall(
|
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
values: Optional[dict] = None,
|
|
||||||
model: Optional[type[TModel]] = None,
|
|
||||||
) -> list[TModel]:
|
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.fetchall(query, values, model)
|
result = await conn.execute(query, values)
|
||||||
|
return await result.fetchall()
|
||||||
|
|
||||||
async def fetchone(
|
async def fetchone(self, query: str, values: tuple = ()):
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
values: Optional[dict] = None,
|
|
||||||
model: Optional[type[TModel]] = None,
|
|
||||||
) -> TModel:
|
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.fetchone(query, values, model)
|
result = await conn.execute(query, values)
|
||||||
|
row = await result.fetchone()
|
||||||
async def insert(self, table_name: str, model: BaseModel) -> None:
|
await result.close()
|
||||||
async with self.connect() as conn:
|
return row
|
||||||
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(
|
async def fetch_page(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
where: Optional[list[str]] = None,
|
where: Optional[list[str]] = None,
|
||||||
values: Optional[dict] = None,
|
values: Optional[list[str]] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[type[TModel]] = None,
|
model: Optional[type[TRowModel]] = None,
|
||||||
group_by: Optional[list[str]] = None,
|
group_by: Optional[list[str]] = None,
|
||||||
) -> Page[TModel]:
|
) -> Page[TRowModel]:
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||||
|
|
||||||
async def execute(self, query: str, values: Optional[dict] = None):
|
async def execute(self, query: str, values: tuple = ()):
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.execute(query, values)
|
return await conn.execute(query, values)
|
||||||
|
|
||||||
@@ -424,6 +371,12 @@ class Operator(Enum):
|
|||||||
raise ValueError("Unknown SQL Operator")
|
raise ValueError("Unknown SQL Operator")
|
||||||
|
|
||||||
|
|
||||||
|
class FromRowModel(BaseModel):
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
class FilterModel(BaseModel):
|
class FilterModel(BaseModel):
|
||||||
__search_fields__: list[str] = []
|
__search_fields__: list[str] = []
|
||||||
__sort_fields__: Optional[list[str]] = None
|
__sort_fields__: Optional[list[str]] = None
|
||||||
@@ -431,6 +384,7 @@ class FilterModel(BaseModel):
|
|||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
TModel = TypeVar("TModel", bound=BaseModel)
|
TModel = TypeVar("TModel", bound=BaseModel)
|
||||||
|
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
|
||||||
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
|
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
|
||||||
|
|
||||||
|
|
||||||
@@ -442,13 +396,12 @@ class Page(BaseModel, Generic[T]):
|
|||||||
class Filter(BaseModel, Generic[TFilterModel]):
|
class Filter(BaseModel, Generic[TFilterModel]):
|
||||||
field: str
|
field: str
|
||||||
op: Operator = Operator.EQ
|
op: Operator = Operator.EQ
|
||||||
|
values: list[Any]
|
||||||
|
|
||||||
model: Optional[type[TFilterModel]]
|
model: Optional[type[TFilterModel]]
|
||||||
values: Optional[dict] = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_query(
|
def parse_query(cls, key: str, raw_values: list[Any], model: type[TFilterModel]):
|
||||||
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
|
|
||||||
):
|
|
||||||
# Key format:
|
# Key format:
|
||||||
# key[operator]
|
# key[operator]
|
||||||
# e.g. name[eq]
|
# e.g. name[eq]
|
||||||
@@ -464,12 +417,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
|
|
||||||
if field in model.__fields__:
|
if field in model.__fields__:
|
||||||
compare_field = model.__fields__[field]
|
compare_field = model.__fields__[field]
|
||||||
values: dict = {}
|
values = []
|
||||||
for raw_value in raw_values:
|
for raw_value in raw_values:
|
||||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors=[errors], model=model)
|
raise ValidationError(errors=[errors], model=model)
|
||||||
values[f"{field}__{i}"] = validated
|
values.append(validated)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown filter field")
|
raise ValueError("Unknown filter field")
|
||||||
|
|
||||||
@@ -477,17 +430,13 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def statement(self):
|
def statement(self):
|
||||||
stmt = []
|
assert self.model, "Model is required for statement generation"
|
||||||
for key in self.values.keys() if self.values else []:
|
placeholder = get_placeholder(self.model, self.field)
|
||||||
clean_key = key.split("__")[0]
|
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||||
if (
|
placeholders = ", ".join([placeholder] * len(self.values))
|
||||||
self.model
|
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||||
and self.model.__fields__[clean_key].type_ == datetime.datetime
|
else:
|
||||||
):
|
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||||
placeholder = compat_timestamp_placeholder(key)
|
|
||||||
else:
|
|
||||||
placeholder = f":{key}"
|
|
||||||
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
|
|
||||||
return " OR ".join(stmt)
|
return " OR ".join(stmt)
|
||||||
|
|
||||||
|
|
||||||
@@ -538,11 +487,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
where_stmts.append(page_filter.statement)
|
where_stmts.append(page_filter.statement)
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
fields = self.model.__search_fields__
|
|
||||||
if DB_TYPE == POSTGRES:
|
if DB_TYPE == POSTGRES:
|
||||||
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
|
where_stmts.append(
|
||||||
|
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
|
||||||
|
)
|
||||||
elif DB_TYPE == SQLITE:
|
elif DB_TYPE == SQLITE:
|
||||||
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
|
where_stmts.append(
|
||||||
|
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
|
||||||
|
)
|
||||||
if where_stmts:
|
if where_stmts:
|
||||||
return "WHERE " + " AND ".join(where_stmts)
|
return "WHERE " + " AND ".join(where_stmts)
|
||||||
return ""
|
return ""
|
||||||
@@ -552,113 +504,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def values(self, values: Optional[dict] = None) -> dict:
|
def values(self, values: Optional[list[str]] = None) -> tuple:
|
||||||
if not values:
|
if not values:
|
||||||
values = {}
|
values = []
|
||||||
if self.filters:
|
if self.filters:
|
||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
if page_filter.values:
|
values.extend(page_filter.values)
|
||||||
for key, value in page_filter.values.items():
|
|
||||||
values[key] = value
|
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
values["search"] = f"%{self.search}%"
|
values.append(f"%{self.search}%")
|
||||||
return values
|
return tuple(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
|
|
||||||
|
|||||||
+28
-48
@@ -14,18 +14,11 @@ from lnbits.core.crud import (
|
|||||||
get_account,
|
get_account,
|
||||||
get_account_by_email,
|
get_account_by_email,
|
||||||
get_account_by_username,
|
get_account_by_username,
|
||||||
|
get_user,
|
||||||
get_user_active_extensions_ids,
|
get_user_active_extensions_ids,
|
||||||
get_user_from_account,
|
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
|
||||||
AccessTokenPayload,
|
|
||||||
Account,
|
|
||||||
KeyType,
|
|
||||||
SimpleStatus,
|
|
||||||
User,
|
|
||||||
WalletTypeInfo,
|
|
||||||
)
|
|
||||||
from lnbits.db import Filter, Filters, TFilterModel
|
from lnbits.db import Filter, Filters, TFilterModel
|
||||||
from lnbits.settings import AuthMethods, settings
|
from lnbits.settings import AuthMethods, settings
|
||||||
|
|
||||||
@@ -66,7 +59,7 @@ class KeyChecker(SecurityBase):
|
|||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.model: APIKey = openapi_model # type: ignore
|
self.model: APIKey = openapi_model
|
||||||
|
|
||||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||||
|
|
||||||
@@ -102,6 +95,15 @@ class KeyChecker(SecurityBase):
|
|||||||
return WalletTypeInfo(key_type, wallet)
|
return WalletTypeInfo(key_type, wallet)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_key_type(
|
||||||
|
request: Request,
|
||||||
|
api_key_header: str = Security(api_key_header),
|
||||||
|
api_key_query: str = Security(api_key_query),
|
||||||
|
) -> WalletTypeInfo:
|
||||||
|
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
|
||||||
|
return await check(request)
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_key(
|
async def require_admin_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header),
|
||||||
@@ -145,16 +147,14 @@ async def check_user_exists(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||||
|
|
||||||
if not account:
|
if not account or not settings.is_user_allowed(account.id):
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
|
||||||
|
|
||||||
if not settings.is_user_allowed(account.id):
|
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||||
|
|
||||||
user = await get_user_from_account(account)
|
user = await get_user(account.id)
|
||||||
if not user:
|
assert user, "User not found for account."
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
|
||||||
await _check_user_extension_access(user.id, r["path"])
|
await _check_user_extension_access(user.id, r["path"])
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -171,16 +171,6 @@ async def optional_user_id(
|
|||||||
return None
|
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:
|
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -214,9 +204,9 @@ def parse_filters(model: Type[TFilterModel]):
|
|||||||
):
|
):
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
filters = []
|
filters = []
|
||||||
for i, key in enumerate(params.keys()):
|
for key in params.keys():
|
||||||
try:
|
try:
|
||||||
filters.append(Filter.parse_query(key, params.getlist(key), model, i))
|
filters.append(Filter.parse_query(key, params.getlist(key), model))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -264,17 +254,17 @@ async def _check_user_extension_access(user_id: str, current_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_account_from_token(access_token) -> Optional[Account]:
|
async def _get_account_from_token(access_token):
|
||||||
try:
|
try:
|
||||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
user = await _get_user_from_jwt_payload(payload)
|
if "sub" in payload and payload.get("sub"):
|
||||||
if not user:
|
return await get_account_by_username(str(payload.get("sub")))
|
||||||
raise HTTPException(
|
if "usr" in payload and payload.get("usr"):
|
||||||
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
|
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 user
|
|
||||||
|
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||||
except jwt.ExpiredSignatureError as exc:
|
except jwt.ExpiredSignatureError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||||
@@ -282,13 +272,3 @@ async def _get_account_from_token(access_token) -> Optional[Account]:
|
|||||||
except jwt.PyJWTError as exc:
|
except jwt.PyJWTError as exc:
|
||||||
logger.debug(exc)
|
logger.debug(exc)
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from 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
|
|
||||||
|
|||||||
+16
-22
@@ -23,6 +23,14 @@ class InvoiceError(Exception):
|
|||||||
self.status = status
|
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]:
|
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||||
# Only the browser sends "text/html" request
|
# Only the browser sends "text/html" request
|
||||||
# not fail proof, but everything else get's a JSON response
|
# not fail proof, but everything else get's a JSON response
|
||||||
@@ -55,9 +63,7 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
def register_exception_handler(app: FastAPI):
|
||||||
"""Register exception handlers for the FastAPI app"""
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def exception_handler(request: Request, exc: Exception):
|
async def exception_handler(request: Request, exc: Exception):
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
@@ -68,26 +74,8 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
content={"detail": str(exc)},
|
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)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(
|
async def validation_exception_handler(
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
@@ -98,6 +86,8 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
content={"detail": str(exc)},
|
content={"detail": str(exc)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_http_exception_handler(app: FastAPI):
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||||
@@ -106,6 +96,8 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
content={"detail": exc.detail},
|
content={"detail": exc.detail},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_payment_error_handler(app: FastAPI):
|
||||||
@app.exception_handler(PaymentError)
|
@app.exception_handler(PaymentError)
|
||||||
async def payment_error_handler(request: Request, exc: PaymentError):
|
async def payment_error_handler(request: Request, exc: PaymentError):
|
||||||
logger.error(f"{exc.message}, {exc.status}")
|
logger.error(f"{exc.message}, {exc.status}")
|
||||||
@@ -114,6 +106,8 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
content={"detail": exc.message, "status": exc.status},
|
content={"detail": exc.message, "status": exc.status},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_invoice_error_handler(app: FastAPI):
|
||||||
@app.exception_handler(InvoiceError)
|
@app.exception_handler(InvoiceError)
|
||||||
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
||||||
logger.error(f"{exc.message}, Status: {exc.status}")
|
logger.error(f"{exc.message}, Status: {exc.status}")
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -8,22 +6,16 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, NamedTuple, Optional
|
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from packaging import version
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .helpers import (
|
|
||||||
download_url,
|
|
||||||
file_hash,
|
|
||||||
github_api_get,
|
|
||||||
icon_to_github_url,
|
|
||||||
version_parse,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExplicitRelease(BaseModel):
|
class ExplicitRelease(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -31,7 +23,7 @@ class ExplicitRelease(BaseModel):
|
|||||||
version: str
|
version: str
|
||||||
archive: str
|
archive: str
|
||||||
hash: str
|
hash: str
|
||||||
dependencies: list[str] = []
|
dependencies: List[str] = []
|
||||||
repo: Optional[str]
|
repo: Optional[str]
|
||||||
icon: Optional[str]
|
icon: Optional[str]
|
||||||
short_description: Optional[str]
|
short_description: Optional[str]
|
||||||
@@ -56,9 +48,9 @@ class GitHubRelease(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Manifest(BaseModel):
|
class Manifest(BaseModel):
|
||||||
featured: list[str] = []
|
featured: List[str] = []
|
||||||
extensions: list[ExplicitRelease] = []
|
extensions: List["ExplicitRelease"] = []
|
||||||
repos: list[GitHubRelease] = []
|
repos: List["GitHubRelease"] = []
|
||||||
|
|
||||||
|
|
||||||
class GitHubRepoRelease(BaseModel):
|
class GitHubRepoRelease(BaseModel):
|
||||||
@@ -89,17 +81,6 @@ class ExtensionConfig(BaseModel):
|
|||||||
return True
|
return True
|
||||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_github_release_config(
|
|
||||||
cls, org: str, repo: str, tag_name: str
|
|
||||||
) -> Optional[ExtensionConfig]:
|
|
||||||
config_url = (
|
|
||||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
|
||||||
)
|
|
||||||
error_msg = "Cannot fetch GitHub extension config"
|
|
||||||
config = await github_api_get(config_url, error_msg)
|
|
||||||
return ExtensionConfig.parse_obj(config)
|
|
||||||
|
|
||||||
|
|
||||||
class ReleasePaymentInfo(BaseModel):
|
class ReleasePaymentInfo(BaseModel):
|
||||||
amount: Optional[int] = None
|
amount: Optional[int] = None
|
||||||
@@ -109,8 +90,8 @@ class ReleasePaymentInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PayToEnableInfo(BaseModel):
|
class PayToEnableInfo(BaseModel):
|
||||||
amount: int
|
required: Optional[bool] = False
|
||||||
required: bool = False
|
amount: Optional[int] = None
|
||||||
wallet: Optional[str] = None
|
wallet: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +101,6 @@ class UserExtensionInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserExtension(BaseModel):
|
class UserExtension(BaseModel):
|
||||||
user: str
|
|
||||||
extension: str
|
extension: str
|
||||||
active: bool
|
active: bool
|
||||||
extra: Optional[UserExtensionInfo] = None
|
extra: Optional[UserExtensionInfo] = None
|
||||||
@@ -132,7 +112,7 @@ class UserExtension(BaseModel):
|
|||||||
return self.extra.paid_to_enable is True
|
return self.extra.paid_to_enable is True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, data: dict) -> UserExtension:
|
def from_row(cls, data: dict) -> "UserExtension":
|
||||||
ext = UserExtension(**data)
|
ext = UserExtension(**data)
|
||||||
ext.extra = (
|
ext.extra = (
|
||||||
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
||||||
@@ -142,6 +122,124 @@ class UserExtension(BaseModel):
|
|||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
|
||||||
|
def download_url(url, save_path):
|
||||||
|
with request.urlopen(url, timeout=60) as dl_file:
|
||||||
|
with open(save_path, "wb") as out_file:
|
||||||
|
out_file.write(dl_file.read())
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(filename):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
b = bytearray(128 * 1024)
|
||||||
|
mv = memoryview(b)
|
||||||
|
with open(filename, "rb", buffering=0) as f:
|
||||||
|
while n := f.readinto(mv):
|
||||||
|
h.update(mv[:n])
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_github_repo_info(
|
||||||
|
org: str, repository: str
|
||||||
|
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||||
|
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||||
|
error_msg = "Cannot fetch extension repo"
|
||||||
|
repo = await github_api_get(repo_url, error_msg)
|
||||||
|
github_repo = GitHubRepo.parse_obj(repo)
|
||||||
|
|
||||||
|
lates_release_url = (
|
||||||
|
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||||
|
)
|
||||||
|
error_msg = "Cannot fetch extension releases"
|
||||||
|
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
||||||
|
|
||||||
|
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||||
|
error_msg = "Cannot fetch config for extension"
|
||||||
|
config = await github_api_get(config_url, error_msg)
|
||||||
|
|
||||||
|
return (
|
||||||
|
github_repo,
|
||||||
|
GitHubRepoRelease.parse_obj(latest_release),
|
||||||
|
ExtensionConfig.parse_obj(config),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_manifest(url) -> Manifest:
|
||||||
|
error_msg = "Cannot fetch extensions manifest"
|
||||||
|
manifest = await github_api_get(url, error_msg)
|
||||||
|
return Manifest.parse_obj(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||||
|
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||||
|
error_msg = "Cannot fetch extension releases"
|
||||||
|
releases = await github_api_get(releases_url, error_msg)
|
||||||
|
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_github_release_config(
|
||||||
|
org: str, repo: str, tag_name: str
|
||||||
|
) -> Optional[ExtensionConfig]:
|
||||||
|
config_url = (
|
||||||
|
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||||
|
)
|
||||||
|
error_msg = "Cannot fetch GitHub extension config"
|
||||||
|
config = await github_api_get(config_url, error_msg)
|
||||||
|
return ExtensionConfig.parse_obj(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
if settings.lnbits_ext_github_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_release_payment_info(
|
||||||
|
url: str, amount: Optional[int] = None
|
||||||
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
|
if amount:
|
||||||
|
url = f"{url}?amount={amount}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return ReleasePaymentInfo(**resp.json())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_release_details(details_link: str) -> Optional[dict]:
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(details_link)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if "description_md" in data:
|
||||||
|
resp = await client.get(data["description_md"])
|
||||||
|
if not resp.is_error:
|
||||||
|
data["description_md"] = resp.text
|
||||||
|
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
_, _, *rest = path.split("/")
|
||||||
|
tail = "/".join(rest)
|
||||||
|
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||||
|
|
||||||
|
|
||||||
class Extension(NamedTuple):
|
class Extension(NamedTuple):
|
||||||
code: str
|
code: str
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
@@ -149,7 +247,7 @@ class Extension(NamedTuple):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
short_description: Optional[str] = None
|
short_description: Optional[str] = None
|
||||||
tile: Optional[str] = None
|
tile: Optional[str] = None
|
||||||
contributors: Optional[list[str]] = None
|
contributors: Optional[List[str]] = None
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
migration_module: Optional[str] = None
|
migration_module: Optional[str] = None
|
||||||
db_name: Optional[str] = None
|
db_name: Optional[str] = None
|
||||||
@@ -171,7 +269,7 @@ class Extension(NamedTuple):
|
|||||||
return self.upgrade_hash != ""
|
return self.upgrade_hash != ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
|
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||||
return Extension(
|
return Extension(
|
||||||
code=ext_info.id,
|
code=ext_info.id,
|
||||||
is_valid=True,
|
is_valid=True,
|
||||||
@@ -180,43 +278,22 @@ class Extension(NamedTuple):
|
|||||||
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
|
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_valid_extensions(
|
|
||||||
cls, include_deactivated: Optional[bool] = True
|
|
||||||
) -> list[Extension]:
|
|
||||||
valid_extensions = [
|
|
||||||
extension for extension in cls._extensions() if extension.is_valid
|
|
||||||
]
|
|
||||||
|
|
||||||
if include_deactivated:
|
# All subdirectories in the current directory, not recursive.
|
||||||
return valid_extensions
|
|
||||||
|
|
||||||
if settings.lnbits_extensions_deactivate_all:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
class ExtensionManager:
|
||||||
e
|
def __init__(self) -> None:
|
||||||
for e in valid_extensions
|
|
||||||
if e.code not in settings.lnbits_deactivated_extensions
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_valid_extension(
|
|
||||||
cls, ext_id: str, include_deactivated: Optional[bool] = True
|
|
||||||
) -> Optional[Extension]:
|
|
||||||
all_extensions = cls.get_valid_extensions(include_deactivated)
|
|
||||||
return next((e for e in all_extensions if e.code == ext_id), None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extensions(cls) -> list[Extension]:
|
|
||||||
p = Path(settings.lnbits_extensions_path, "extensions")
|
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||||
Path(p).mkdir(parents=True, exist_ok=True)
|
Path(p).mkdir(parents=True, exist_ok=True)
|
||||||
extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
|
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extensions(self) -> List[Extension]:
|
||||||
# todo: remove this property somehow, it is too expensive
|
# todo: remove this property somehow, it is too expensive
|
||||||
output: list[Extension] = []
|
output: List[Extension] = []
|
||||||
|
|
||||||
for extension_folder in extension_folders:
|
for extension_folder in self._extension_folders:
|
||||||
extension_code = extension_folder.parts[-1]
|
extension_code = extension_folder.parts[-1]
|
||||||
try:
|
try:
|
||||||
with open(extension_folder / "config.json") as json_file:
|
with open(extension_folder / "config.json") as json_file:
|
||||||
@@ -279,27 +356,13 @@ class ExtensionRelease(BaseModel):
|
|||||||
if not self.pay_link:
|
if not self.pay_link:
|
||||||
return
|
return
|
||||||
|
|
||||||
payment_info = await self.fetch_release_payment_info()
|
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||||
self.cost_sats = payment_info.amount if payment_info else None
|
self.cost_sats = payment_info.amount if payment_info else None
|
||||||
|
|
||||||
async def fetch_release_payment_info(
|
|
||||||
self, amount: Optional[int] = None
|
|
||||||
) -> Optional[ReleasePaymentInfo]:
|
|
||||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
|
||||||
assert url, "Missing URL for payment info."
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return ReleasePaymentInfo(**resp.json())
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_github_release(
|
def from_github_release(
|
||||||
cls, source_repo: str, r: GitHubRepoRelease
|
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||||
) -> ExtensionRelease:
|
) -> "ExtensionRelease":
|
||||||
return ExtensionRelease(
|
return ExtensionRelease(
|
||||||
name=r.name,
|
name=r.name,
|
||||||
description=r.name,
|
description=r.name,
|
||||||
@@ -314,8 +377,8 @@ class ExtensionRelease(BaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_explicit_release(
|
def from_explicit_release(
|
||||||
cls, source_repo: str, e: ExplicitRelease
|
cls, source_repo: str, e: "ExplicitRelease"
|
||||||
) -> ExtensionRelease:
|
) -> "ExtensionRelease":
|
||||||
return ExtensionRelease(
|
return ExtensionRelease(
|
||||||
name=e.name,
|
name=e.name,
|
||||||
version=e.version,
|
version=e.version,
|
||||||
@@ -334,9 +397,9 @@ class ExtensionRelease(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||||
try:
|
try:
|
||||||
github_releases = await cls.fetch_github_releases(org, repo)
|
github_releases = await fetch_github_releases(org, repo)
|
||||||
return [
|
return [
|
||||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||||
for r in github_releases
|
for r in github_releases
|
||||||
@@ -345,65 +408,30 @@ class ExtensionRelease(BaseModel):
|
|||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_github_releases(
|
|
||||||
cls, org: str, repo: str
|
|
||||||
) -> list[GitHubRepoRelease]:
|
|
||||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
|
||||||
error_msg = "Cannot fetch extension releases"
|
|
||||||
releases = await github_api_get(releases_url, error_msg)
|
|
||||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(details_link)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if "description_md" in data:
|
|
||||||
resp = await client.get(data["description_md"])
|
|
||||||
if not resp.is_error:
|
|
||||||
data["description_md"] = resp.text
|
|
||||||
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
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):
|
class InstallableExtension(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
version: str
|
|
||||||
active: Optional[bool] = False
|
active: Optional[bool] = False
|
||||||
short_description: Optional[str] = None
|
short_description: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
|
dependencies: List[str] = []
|
||||||
|
is_admin_only: bool = False
|
||||||
stars: int = 0
|
stars: int = 0
|
||||||
meta: Optional[ExtensionMeta] = None
|
featured = False
|
||||||
|
latest_release: Optional[ExtensionRelease] = None
|
||||||
@property
|
installed_release: Optional[ExtensionRelease] = None
|
||||||
def is_admin_only(self) -> bool:
|
payments: List[ReleasePaymentInfo] = []
|
||||||
return self.id in settings.lnbits_admin_extensions
|
pay_to_enable: Optional[PayToEnableInfo] = None
|
||||||
|
archive: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hash(self) -> str:
|
def hash(self) -> str:
|
||||||
if self.meta and self.meta.installed_release:
|
if self.installed_release:
|
||||||
if self.meta.installed_release.hash:
|
if self.installed_release.hash:
|
||||||
return self.meta.installed_release.hash
|
return self.installed_release.hash
|
||||||
m = hashlib.sha256()
|
m = hashlib.sha256()
|
||||||
m.update(f"{self.meta.installed_release.archive}".encode())
|
m.update(f"{self.installed_release.archive}".encode())
|
||||||
return m.hexdigest()
|
return m.hexdigest()
|
||||||
return "not-installed"
|
return "not-installed"
|
||||||
|
|
||||||
@@ -441,15 +469,15 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def installed_version(self) -> str:
|
def installed_version(self) -> str:
|
||||||
if self.meta and self.meta.installed_release:
|
if self.installed_release:
|
||||||
return self.meta.installed_release.version
|
return self.installed_release.version
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_payment(self) -> bool:
|
def requires_payment(self) -> bool:
|
||||||
if not self.meta or not self.meta.pay_to_enable:
|
if not self.pay_to_enable:
|
||||||
return False
|
return False
|
||||||
return self.meta.pay_to_enable.required is True
|
return self.pay_to_enable.required is True
|
||||||
|
|
||||||
async def download_archive(self):
|
async def download_archive(self):
|
||||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||||
@@ -457,14 +485,12 @@ class InstallableExtension(BaseModel):
|
|||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
try:
|
try:
|
||||||
assert (
|
assert self.installed_release, "installed_release is none."
|
||||||
self.meta and self.meta.installed_release
|
|
||||||
), "installed_release is none."
|
|
||||||
|
|
||||||
self._restore_payment_info()
|
self._restore_payment_info()
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
download_url, self.meta.installed_release.archive_url, ext_zip_file
|
download_url, self.installed_release.archive_url, ext_zip_file
|
||||||
)
|
)
|
||||||
|
|
||||||
self._remember_payment_info()
|
self._remember_payment_info()
|
||||||
@@ -474,11 +500,7 @@ class InstallableExtension(BaseModel):
|
|||||||
raise AssertionError("Cannot fetch extension archive file") from exc
|
raise AssertionError("Cannot fetch extension archive file") from exc
|
||||||
|
|
||||||
archive_hash = file_hash(ext_zip_file)
|
archive_hash = file_hash(ext_zip_file)
|
||||||
if (
|
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||||
self.meta
|
|
||||||
and self.meta.installed_release.hash
|
|
||||||
and self.meta.installed_release.hash != archive_hash
|
|
||||||
):
|
|
||||||
# remove downloaded archive
|
# remove downloaded archive
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
@@ -512,18 +534,27 @@ class InstallableExtension(BaseModel):
|
|||||||
self.short_description = config_json.get("short_description")
|
self.short_description = config_json.get("short_description")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.meta
|
self.installed_release
|
||||||
and self.meta.installed_release
|
and self.installed_release.is_github_release
|
||||||
and self.meta.installed_release.is_github_release
|
|
||||||
and config_json.get("tile")
|
and config_json.get("tile")
|
||||||
):
|
):
|
||||||
self.icon = icon_to_github_url(
|
self.icon = icon_to_github_url(
|
||||||
self.meta.installed_release.source_repo, config_json.get("tile")
|
self.installed_release.source_repo, config_json.get("tile")
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.rmtree(self.ext_dir, True)
|
shutil.rmtree(self.ext_dir, True)
|
||||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||||
|
|
||||||
|
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
|
||||||
|
"""
|
||||||
|
Update the list of upgraded extensions. The middleware will perform
|
||||||
|
redirects based on this
|
||||||
|
"""
|
||||||
|
if upgrade_hash:
|
||||||
|
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
|
||||||
|
|
||||||
|
settings.lnbits_all_extensions_ids.add(self.id)
|
||||||
|
|
||||||
def clean_extension_files(self):
|
def clean_extension_files(self):
|
||||||
# remove downloaded archive
|
# remove downloaded archive
|
||||||
@@ -538,78 +569,87 @@ class InstallableExtension(BaseModel):
|
|||||||
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
if not self.meta or not self.meta.latest_release:
|
if not self.latest_release:
|
||||||
meta = self.meta or ExtensionMeta()
|
self.latest_release = release
|
||||||
meta.latest_release = release
|
|
||||||
self.meta = meta
|
|
||||||
return
|
return
|
||||||
if version_parse(self.meta.latest_release.version) < version_parse(
|
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||||
release.version
|
self.latest_release = release
|
||||||
):
|
|
||||||
self.meta.latest_release = release
|
|
||||||
|
|
||||||
def find_existing_payment(
|
def find_existing_payment(
|
||||||
self, pay_link: Optional[str]
|
self, pay_link: Optional[str]
|
||||||
) -> Optional[ReleasePaymentInfo]:
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
if not pay_link or not self.meta or not self.meta.payments:
|
if not pay_link:
|
||||||
return None
|
return None
|
||||||
return next(
|
return next(
|
||||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
(p for p in self.payments if p.pay_link == pay_link),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
def _restore_payment_info(self):
|
||||||
if (
|
if not self.installed_release:
|
||||||
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
|
return
|
||||||
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
|
if not self.installed_release.pay_link:
|
||||||
|
return
|
||||||
|
if self.installed_release.payment_hash:
|
||||||
|
return
|
||||||
|
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||||
if payment_info:
|
if payment_info:
|
||||||
self.meta.installed_release.payment_hash = payment_info.payment_hash
|
self.installed_release.payment_hash = payment_info.payment_hash
|
||||||
|
|
||||||
def _remember_payment_info(self):
|
def _remember_payment_info(self):
|
||||||
if (
|
if not self.installed_release or not self.installed_release.pay_link:
|
||||||
not self.meta
|
|
||||||
or not self.meta.installed_release
|
|
||||||
or not self.meta.installed_release.pay_link
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
payment_info = ReleasePaymentInfo(
|
payment_info = ReleasePaymentInfo(
|
||||||
amount=self.meta.installed_release.cost_sats,
|
amount=self.installed_release.cost_sats,
|
||||||
pay_link=self.meta.installed_release.pay_link,
|
pay_link=self.installed_release.pay_link,
|
||||||
payment_hash=self.meta.installed_release.payment_hash,
|
payment_hash=self.installed_release.payment_hash,
|
||||||
)
|
)
|
||||||
self.meta.payments = [
|
self.payments = [
|
||||||
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
|
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||||
]
|
]
|
||||||
self.meta.payments.append(payment_info)
|
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]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_github_release(
|
async def from_github_release(
|
||||||
cls, github_release: GitHubRelease
|
cls, github_release: GitHubRelease
|
||||||
) -> Optional[InstallableExtension]:
|
) -> Optional["InstallableExtension"]:
|
||||||
try:
|
try:
|
||||||
repo, latest_release, config = await cls.fetch_github_repo_info(
|
repo, latest_release, config = await fetch_github_repo_info(
|
||||||
github_release.organisation, github_release.repository
|
github_release.organisation, github_release.repository
|
||||||
)
|
)
|
||||||
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
||||||
return InstallableExtension(
|
return InstallableExtension(
|
||||||
id=github_release.id,
|
id=github_release.id,
|
||||||
name=config.name,
|
name=config.name,
|
||||||
version=latest_release.tag_name,
|
|
||||||
short_description=config.short_description,
|
short_description=config.short_description,
|
||||||
stars=int(repo.stargazers_count),
|
stars=int(repo.stargazers_count),
|
||||||
icon=icon_to_github_url(
|
icon=icon_to_github_url(
|
||||||
source_repo,
|
source_repo,
|
||||||
config.tile,
|
config.tile,
|
||||||
),
|
),
|
||||||
meta=ExtensionMeta(
|
latest_release=ExtensionRelease.from_github_release(
|
||||||
latest_release=ExtensionRelease.from_github_release(
|
source_repo, latest_release
|
||||||
source_repo, latest_release
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -617,27 +657,26 @@ class InstallableExtension(BaseModel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
|
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension":
|
||||||
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
|
|
||||||
return InstallableExtension(
|
return InstallableExtension(
|
||||||
id=e.id,
|
id=e.id,
|
||||||
name=e.name,
|
name=e.name,
|
||||||
version=e.version,
|
archive=e.archive,
|
||||||
short_description=e.short_description,
|
short_description=e.short_description,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
meta=meta,
|
dependencies=e.dependencies,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_installable_extensions(
|
async def get_installable_extensions(
|
||||||
cls,
|
cls,
|
||||||
) -> list[InstallableExtension]:
|
) -> List["InstallableExtension"]:
|
||||||
extension_list: list[InstallableExtension] = []
|
extension_list: List[InstallableExtension] = []
|
||||||
extension_id_list: list[str] = []
|
extension_id_list: List[str] = []
|
||||||
|
|
||||||
for url in settings.lnbits_extensions_manifests:
|
for url in settings.lnbits_extensions_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await fetch_manifest(url)
|
||||||
|
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
ext = await InstallableExtension.from_github_release(r)
|
ext = await InstallableExtension.from_github_release(r)
|
||||||
@@ -646,13 +685,11 @@ class InstallableExtension(BaseModel):
|
|||||||
existing_ext = next(
|
existing_ext = next(
|
||||||
(ee for ee in extension_list if ee.id == r.id), None
|
(ee for ee in extension_list if ee.id == r.id), None
|
||||||
)
|
)
|
||||||
if existing_ext and ext.meta:
|
if existing_ext:
|
||||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
existing_ext.check_latest_version(ext.latest_release)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = ext.meta or ExtensionMeta()
|
ext.featured = ext.id in manifest.featured
|
||||||
meta.featured = ext.id in manifest.featured
|
|
||||||
ext.meta = meta
|
|
||||||
extension_list += [ext]
|
extension_list += [ext]
|
||||||
extension_id_list += [ext.id]
|
extension_id_list += [ext.id]
|
||||||
|
|
||||||
@@ -666,9 +703,7 @@ class InstallableExtension(BaseModel):
|
|||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_latest_version(release)
|
ext.check_latest_version(release)
|
||||||
meta = ext.meta or ExtensionMeta()
|
ext.featured = ext.id in manifest.featured
|
||||||
meta.featured = ext.id in manifest.featured
|
|
||||||
ext.meta = meta
|
|
||||||
extension_list += [ext]
|
extension_list += [ext]
|
||||||
extension_id_list += [e.id]
|
extension_id_list += [e.id]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -677,12 +712,12 @@ class InstallableExtension(BaseModel):
|
|||||||
return extension_list
|
return extension_list
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
|
||||||
extension_releases: list[ExtensionRelease] = []
|
extension_releases: List[ExtensionRelease] = []
|
||||||
|
|
||||||
for url in settings.lnbits_extensions_manifests:
|
for url in settings.lnbits_extensions_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
if r.id != ext_id:
|
if r.id != ext_id:
|
||||||
continue
|
continue
|
||||||
@@ -706,8 +741,8 @@ class InstallableExtension(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_release(
|
async def get_extension_release(
|
||||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||||
) -> Optional[ExtensionRelease]:
|
) -> Optional["ExtensionRelease"]:
|
||||||
all_releases: list[ExtensionRelease] = (
|
all_releases: List[ExtensionRelease] = (
|
||||||
await InstallableExtension.get_extension_releases(ext_id)
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
)
|
)
|
||||||
selected_release = [
|
selected_release = [
|
||||||
@@ -720,37 +755,6 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
return selected_release[0] if len(selected_release) != 0 else None
|
return selected_release[0] if len(selected_release) != 0 else None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_github_repo_info(
|
|
||||||
cls, org: str, repository: str
|
|
||||||
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
|
||||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
|
||||||
error_msg = "Cannot fetch extension repo"
|
|
||||||
repo = await github_api_get(repo_url, error_msg)
|
|
||||||
github_repo = GitHubRepo.parse_obj(repo)
|
|
||||||
|
|
||||||
lates_release_url = (
|
|
||||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
|
||||||
)
|
|
||||||
error_msg = "Cannot fetch extension releases"
|
|
||||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
|
||||||
|
|
||||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
|
||||||
error_msg = "Cannot fetch config for extension"
|
|
||||||
config = await github_api_get(config_url, error_msg)
|
|
||||||
|
|
||||||
return (
|
|
||||||
github_repo,
|
|
||||||
GitHubRepoRelease.parse_obj(latest_release),
|
|
||||||
ExtensionConfig.parse_obj(config),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_manifest(cls, url) -> Manifest:
|
|
||||||
error_msg = "Cannot fetch extensions manifest"
|
|
||||||
manifest = await github_api_get(url, error_msg)
|
|
||||||
return Manifest.parse_obj(manifest)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateExtension(BaseModel):
|
class CreateExtension(BaseModel):
|
||||||
ext_id: str
|
ext_id: str
|
||||||
@@ -765,3 +769,32 @@ class ExtensionDetailsRequest(BaseModel):
|
|||||||
ext_id: str
|
ext_id: str
|
||||||
source_repo: str
|
source_repo: str
|
||||||
version: str
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||||
|
valid_extensions = [
|
||||||
|
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||||
|
]
|
||||||
|
|
||||||
|
if include_deactivated:
|
||||||
|
return valid_extensions
|
||||||
|
|
||||||
|
if settings.lnbits_extensions_deactivate_all:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
e
|
||||||
|
for e in valid_extensions
|
||||||
|
if e.code not in settings.lnbits_deactivated_extensions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def version_parse(v: str):
|
||||||
|
"""
|
||||||
|
Wrapper for version.parse() that does not throw if the version is invalid.
|
||||||
|
Instead it return the lowest possible version ("0.0.0")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return version.parse(v)
|
||||||
|
except Exception:
|
||||||
|
return version.parse("0.0.0")
|
||||||
+37
-10
@@ -1,15 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, List, Optional, Type
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
import jwt
|
import jwt
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
from pydantic import BaseModel
|
||||||
from pydantic.schema import field_schema
|
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.jinja2_templating import Jinja2Templates
|
||||||
from lnbits.nodes import get_node_class
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
@@ -17,6 +18,7 @@ from lnbits.settings import settings
|
|||||||
from lnbits.utils.crypto import AESCipher
|
from lnbits.utils.crypto import AESCipher
|
||||||
|
|
||||||
from .db import FilterModel
|
from .db import FilterModel
|
||||||
|
from .extension_manager import get_valid_extensions
|
||||||
|
|
||||||
|
|
||||||
def get_db_vendor_name():
|
def get_db_vendor_name():
|
||||||
@@ -49,7 +51,7 @@ def static_url_for(static: str, path: str) -> str:
|
|||||||
return f"/{static}/{path}?v={settings.server_startup_time}"
|
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"]
|
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
additional_folders += [
|
additional_folders += [
|
||||||
@@ -91,21 +93,19 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
|||||||
settings.lnbits_node_ui and get_node_class() is not None
|
settings.lnbits_node_ui and get_node_class() is not None
|
||||||
)
|
)
|
||||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||||
t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
|
t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
|
||||||
if settings.lnbits_custom_logo:
|
if settings.lnbits_custom_logo:
|
||||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||||
|
|
||||||
if settings.bundle_assets:
|
if settings.bundle_assets:
|
||||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||||
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
|
|
||||||
else:
|
else:
|
||||||
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
||||||
with open(vendor_filepath) as vendor_file:
|
with open(vendor_filepath) as vendor_file:
|
||||||
vendor_files = json.loads(vendor_file.read())
|
vendor_files = json.loads(vendor_file.read())
|
||||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||||
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
|
||||||
|
|
||||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||||
|
|
||||||
@@ -173,6 +173,35 @@ 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 = ?") -> 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 = ?`
|
||||||
|
"""
|
||||||
|
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:
|
def is_valid_email_address(email: str) -> bool:
|
||||||
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
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
|
return re.fullmatch(email_regex, email) is not None
|
||||||
@@ -184,9 +213,7 @@ def is_valid_username(username: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict):
|
def create_access_token(data: dict):
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||||
minutes=settings.auth_token_expire_minutes
|
|
||||||
)
|
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||||
|
|||||||
+72
-7
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any, List, Union
|
from typing import Any, List, Tuple, Union
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
@@ -45,11 +45,16 @@ class InstalledExtensionMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
upgrade_path = next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in settings.lnbits_upgraded_extensions
|
||||||
|
if e.endswith(f"/{top_path}")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
# re-route all trafic if the extension has been upgraded
|
# re-route all trafic if the extension has been upgraded
|
||||||
if top_path in settings.lnbits_upgraded_extensions:
|
if upgrade_path:
|
||||||
upgrade_path = (
|
|
||||||
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
|
|
||||||
)
|
|
||||||
tail = "/".join(rest)
|
tail = "/".join(rest)
|
||||||
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
||||||
|
|
||||||
@@ -113,12 +118,72 @@ class ExtensionsRedirectMiddleware:
|
|||||||
return
|
return
|
||||||
|
|
||||||
req_headers = scope["headers"] if "headers" in scope else []
|
req_headers = scope["headers"] if "headers" in scope else []
|
||||||
redirect = settings.find_extension_redirect(scope["path"], req_headers)
|
redirect = self._find_redirect(scope["path"], req_headers)
|
||||||
if redirect:
|
if redirect:
|
||||||
scope["path"] = redirect.new_path_from(scope["path"])
|
scope["path"] = self._new_path(redirect, scope["path"])
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
for r in settings.lnbits_extensions_redirects
|
||||||
|
if self._redirect_matches(r, path, req_headers)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _redirect_matches(
|
||||||
|
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
|
||||||
|
) -> bool:
|
||||||
|
if "from_path" not in redirect:
|
||||||
|
return False
|
||||||
|
header_filters = (
|
||||||
|
redirect["header_filters"] if "header_filters" in redirect else {}
|
||||||
|
)
|
||||||
|
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
|
||||||
|
header_filters, req_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_headers(
|
||||||
|
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
|
||||||
|
) -> bool:
|
||||||
|
for h in filter_headers:
|
||||||
|
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _has_header(
|
||||||
|
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
|
||||||
|
) -> bool:
|
||||||
|
for h in req_headers:
|
||||||
|
if (
|
||||||
|
h[0].decode().lower() == header[0].lower()
|
||||||
|
and h[1].decode() == header[1]
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
|
||||||
|
redirect_path_elements = redirect_path.split("/")
|
||||||
|
req_path_elements = req_path.split("/")
|
||||||
|
if len(redirect_path) > len(req_path):
|
||||||
|
return False
|
||||||
|
sub_path = req_path_elements[: len(redirect_path_elements)]
|
||||||
|
return redirect_path == "/".join(sub_path)
|
||||||
|
|
||||||
|
def _new_path(self, redirect: dict, req_path: str) -> str:
|
||||||
|
from_path = redirect["from_path"].split("/")
|
||||||
|
redirect_to = redirect["redirect_to_path"].split("/")
|
||||||
|
req_tail_path = req_path.split("/")[len(from_path) :]
|
||||||
|
|
||||||
|
elements = [
|
||||||
|
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
|
||||||
|
]
|
||||||
|
|
||||||
|
return "/" + "/".join(elements)
|
||||||
|
|
||||||
|
|
||||||
def add_ratelimit_middleware(app: FastAPI):
|
def add_ratelimit_middleware(app: FastAPI):
|
||||||
core_app_extra.register_new_ratelimiter()
|
core_app_extra.register_new_ratelimiter()
|
||||||
|
|||||||
+14
-136
@@ -7,6 +7,7 @@ import json
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from os import path
|
from os import path
|
||||||
|
from sqlite3 import Row
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -61,132 +62,26 @@ class ExtensionsInstallSettings(LNbitsSettings):
|
|||||||
lnbits_ext_github_token: str = Field(default="")
|
lnbits_ext_github_token: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
class RedirectPath(BaseModel):
|
|
||||||
ext_id: str
|
|
||||||
from_path: str
|
|
||||||
redirect_to_path: str
|
|
||||||
header_filters: dict = {}
|
|
||||||
|
|
||||||
def in_conflict(self, other: RedirectPath) -> bool:
|
|
||||||
if self.ext_id == other.ext_id:
|
|
||||||
return False
|
|
||||||
return self.redirect_matches(
|
|
||||||
other.from_path, list(other.header_filters.items())
|
|
||||||
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
|
|
||||||
|
|
||||||
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
|
|
||||||
for other in others:
|
|
||||||
if self.in_conflict(other):
|
|
||||||
return other
|
|
||||||
return None
|
|
||||||
|
|
||||||
def new_path_from(self, req_path: str) -> str:
|
|
||||||
from_path = self.from_path.split("/")
|
|
||||||
redirect_to = self.redirect_to_path.split("/")
|
|
||||||
req_tail_path = req_path.split("/")[len(from_path) :]
|
|
||||||
|
|
||||||
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
|
|
||||||
|
|
||||||
return "/" + "/".join(elements)
|
|
||||||
|
|
||||||
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
|
|
||||||
return self._has_common_path(path) and self._has_headers(req_headers)
|
|
||||||
|
|
||||||
def _has_common_path(self, req_path: str) -> bool:
|
|
||||||
if len(self.from_path) > len(req_path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
redirect_path_elements = self.from_path.split("/")
|
|
||||||
req_path_elements = req_path.split("/")
|
|
||||||
|
|
||||||
sub_path = req_path_elements[: len(redirect_path_elements)]
|
|
||||||
return self.from_path == "/".join(sub_path)
|
|
||||||
|
|
||||||
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
|
|
||||||
for h in self.header_filters:
|
|
||||||
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _has_header(
|
|
||||||
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
|
|
||||||
) -> bool:
|
|
||||||
for h in req_headers:
|
|
||||||
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class InstalledExtensionsSettings(LNbitsSettings):
|
class InstalledExtensionsSettings(LNbitsSettings):
|
||||||
# installed extensions that have been deactivated
|
# installed extensions that have been deactivated
|
||||||
lnbits_deactivated_extensions: set[str] = Field(default=[])
|
lnbits_deactivated_extensions: set[str] = Field(default=[])
|
||||||
# upgraded extensions that require API redirects
|
# upgraded extensions that require API redirects
|
||||||
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
|
lnbits_upgraded_extensions: set[str] = Field(default=[])
|
||||||
# list of redirects that extensions want to perform
|
# list of redirects that extensions want to perform
|
||||||
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
|
lnbits_extensions_redirects: list[Any] = Field(default=[])
|
||||||
|
|
||||||
# list of all extension ids
|
# list of all extension ids
|
||||||
lnbits_all_extensions_ids: set[Any] = Field(default=[])
|
lnbits_all_extensions_ids: set[Any] = Field(default=[])
|
||||||
|
|
||||||
def find_extension_redirect(
|
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
|
||||||
self, path: str, req_headers: list[tuple[bytes, bytes]]
|
|
||||||
) -> Optional[RedirectPath]:
|
|
||||||
headers = [(k.decode(), v.decode()) for k, v in req_headers]
|
|
||||||
return next(
|
return next(
|
||||||
(
|
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
|
||||||
r
|
|
||||||
for r in self.lnbits_extensions_redirects
|
|
||||||
if r.redirect_matches(path, headers)
|
|
||||||
),
|
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def activate_extension_paths(
|
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
|
||||||
self,
|
path = settings.extension_upgrade_path(ext_id)
|
||||||
ext_id: str,
|
return path.split("/")[0] if path else None
|
||||||
upgrade_hash: Optional[str] = None,
|
|
||||||
ext_redirects: Optional[list[dict]] = None,
|
|
||||||
):
|
|
||||||
self.lnbits_deactivated_extensions.discard(ext_id)
|
|
||||||
|
|
||||||
"""
|
|
||||||
Update the list of upgraded extensions. The middleware will perform
|
|
||||||
redirects based on this
|
|
||||||
"""
|
|
||||||
if upgrade_hash:
|
|
||||||
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
|
|
||||||
|
|
||||||
if ext_redirects:
|
|
||||||
self._activate_extension_redirects(ext_id, ext_redirects)
|
|
||||||
|
|
||||||
self.lnbits_all_extensions_ids.add(ext_id)
|
|
||||||
|
|
||||||
def deactivate_extension_paths(self, ext_id: str):
|
|
||||||
self.lnbits_deactivated_extensions.add(ext_id)
|
|
||||||
self._remove_extension_redirects(ext_id)
|
|
||||||
|
|
||||||
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
|
|
||||||
ext_redirect_paths = [
|
|
||||||
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
|
|
||||||
]
|
|
||||||
existing_redirects = {
|
|
||||||
r.ext_id
|
|
||||||
for r in self.lnbits_extensions_redirects
|
|
||||||
if r.find_in_conflict(ext_redirect_paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert len(existing_redirects) == 0, (
|
|
||||||
f"Cannot redirect for extension '{ext_id}'."
|
|
||||||
f" Already mapped by {existing_redirects}."
|
|
||||||
)
|
|
||||||
|
|
||||||
self._remove_extension_redirects(ext_id)
|
|
||||||
self.lnbits_extensions_redirects += ext_redirect_paths
|
|
||||||
|
|
||||||
def _remove_extension_redirects(self, ext_id: str):
|
|
||||||
self.lnbits_extensions_redirects = [
|
|
||||||
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ThemesSettings(LNbitsSettings):
|
class ThemesSettings(LNbitsSettings):
|
||||||
@@ -428,22 +323,10 @@ class NodeUISettings(LNbitsSettings):
|
|||||||
class AuthMethods(Enum):
|
class AuthMethods(Enum):
|
||||||
user_id_only = "user-id-only"
|
user_id_only = "user-id-only"
|
||||||
username_and_password = "username-password"
|
username_and_password = "username-password"
|
||||||
nostr_auth_nip98 = "nostr-auth-nip98"
|
|
||||||
google_auth = "google-auth"
|
google_auth = "google-auth"
|
||||||
github_auth = "github-auth"
|
github_auth = "github-auth"
|
||||||
keycloak_auth = "keycloak-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):
|
class AuthSettings(LNbitsSettings):
|
||||||
auth_token_expire_minutes: int = Field(default=525600)
|
auth_token_expire_minutes: int = Field(default=525600)
|
||||||
@@ -454,20 +337,11 @@ class AuthSettings(LNbitsSettings):
|
|||||||
AuthMethods.username_and_password.value,
|
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):
|
def is_auth_method_allowed(self, method: AuthMethods):
|
||||||
return method.value in self.auth_allowed_methods
|
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):
|
class GoogleAuthSettings(LNbitsSettings):
|
||||||
google_client_id: str = Field(default="")
|
google_client_id: str = Field(default="")
|
||||||
google_client_secret: str = Field(default="")
|
google_client_secret: str = Field(default="")
|
||||||
@@ -495,7 +369,6 @@ class EditableSettings(
|
|||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
NodeUISettings,
|
NodeUISettings,
|
||||||
AuthSettings,
|
AuthSettings,
|
||||||
NostrAuthSettings,
|
|
||||||
GoogleAuthSettings,
|
GoogleAuthSettings,
|
||||||
GitHubAuthSettings,
|
GitHubAuthSettings,
|
||||||
KeycloakAuthSettings,
|
KeycloakAuthSettings,
|
||||||
@@ -618,7 +491,7 @@ class ReadOnlySettings(
|
|||||||
PersistenceSettings,
|
PersistenceSettings,
|
||||||
SuperUserSettings,
|
SuperUserSettings,
|
||||||
):
|
):
|
||||||
lnbits_admin_ui: bool = Field(default=True)
|
lnbits_admin_ui: bool = Field(default=False)
|
||||||
|
|
||||||
@validator(
|
@validator(
|
||||||
"lnbits_allowed_funding_sources",
|
"lnbits_allowed_funding_sources",
|
||||||
@@ -634,6 +507,11 @@ class ReadOnlySettings(
|
|||||||
|
|
||||||
|
|
||||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> Settings:
|
||||||
|
data = dict(row)
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+41
-29
File diff suppressed because one or more lines are too long
@@ -530,26 +530,23 @@ video {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode__wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qrcode__wrapper canvas {
|
.qrcode__wrapper canvas {
|
||||||
|
position: relative;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
max-width: 100%;
|
||||||
max-width: 350px;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode__image {
|
.qrcode__image {
|
||||||
position: absolute;
|
|
||||||
max-width: 52px;
|
|
||||||
width: 15%;
|
width: 15%;
|
||||||
|
height: 15%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
left: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -35,9 +35,9 @@ window.localisation.br = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Apagar todas as configurações e redefinir para os padrões.',
|
'Apagar todas as configurações e redefinir para os padrões.',
|
||||||
download_backup: 'Fazer backup do banco de dados',
|
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:
|
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 *',
|
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network 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,9 +58,10 @@ window.localisation.br = {
|
|||||||
donate: 'Doar',
|
donate: 'Doar',
|
||||||
view_github: 'Ver no GitHub',
|
view_github: 'Ver no GitHub',
|
||||||
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
||||||
use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
|
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: '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_max:
|
||||||
|
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
@@ -71,7 +72,7 @@ window.localisation.br = {
|
|||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
credit_label: '{denomination} para creditar',
|
credit_label: '%{denomination} para creditar',
|
||||||
paste: 'Colar',
|
paste: 'Colar',
|
||||||
paste_from_clipboard: 'Cole do clipboard',
|
paste_from_clipboard: 'Cole do clipboard',
|
||||||
paste_request: 'Colar Pedido',
|
paste_request: 'Colar Pedido',
|
||||||
@@ -154,8 +155,8 @@ window.localisation.br = {
|
|||||||
expiry: 'Validade',
|
expiry: 'Validade',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Comprovante de pagamento',
|
payment_proof: 'Comprovante de pagamento',
|
||||||
update_available: 'Atualização {version} disponível!',
|
update_available: 'Atualização %{version} disponível!',
|
||||||
latest_update: 'Você está na versão mais recente {version}.',
|
latest_update: 'Você está na versão mais recente %{version}.',
|
||||||
notifications: 'Notificações',
|
notifications: 'Notificações',
|
||||||
no_notifications: 'Sem notificações',
|
no_notifications: 'Sem notificações',
|
||||||
notifications_disabled:
|
notifications_disabled:
|
||||||
@@ -246,8 +247,8 @@ window.localisation.br = {
|
|||||||
look_and_feel: 'Aparência',
|
look_and_feel: 'Aparência',
|
||||||
language: 'Idioma',
|
language: 'Idioma',
|
||||||
color_scheme: 'Esquema de Cores',
|
color_scheme: 'Esquema de Cores',
|
||||||
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
|
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
||||||
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
|
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
|
||||||
release_details_error: 'Não é possível obter os detalhes da versão.',
|
release_details_error: 'Não é possível obter os detalhes da versão.',
|
||||||
pay_from_wallet: 'Pagar com a Carteira',
|
pay_from_wallet: 'Pagar com a Carteira',
|
||||||
show_qr: 'Exibir QR',
|
show_qr: 'Exibir QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.cn = {
|
|||||||
reset_defaults: '重置为默认设置',
|
reset_defaults: '重置为默认设置',
|
||||||
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
||||||
download_backup: '下载数据库备份',
|
download_backup: '下载数据库备份',
|
||||||
name_your_wallet: '给你的 {name}钱包起个名字',
|
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'成功创建虚拟资金({amount} sats)。付款取决于资金来源的实际资金。',
|
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
|
||||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||||
@@ -56,9 +56,9 @@ window.localisation.cn = {
|
|||||||
donate: '捐献',
|
donate: '捐献',
|
||||||
view_github: '在GitHub上查看',
|
view_github: '在GitHub上查看',
|
||||||
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
||||||
use_with_caution: '请谨慎使用 - {name}钱包还处于测试版阶段',
|
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
|
||||||
service_fee: '服务费:{amount}% 每笔交易',
|
service_fee: '服务费:%{amount}% 每笔交易',
|
||||||
service_fee_max: '服务费:{amount}% 每笔交易(最高 {max} sats)',
|
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats)',
|
||||||
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
||||||
toggle_darkmode: '切换暗黑模式',
|
toggle_darkmode: '切换暗黑模式',
|
||||||
payment_reactions: '支付反应',
|
payment_reactions: '支付反应',
|
||||||
@@ -68,7 +68,7 @@ window.localisation.cn = {
|
|||||||
lnbits_version: 'LNbits版本',
|
lnbits_version: 'LNbits版本',
|
||||||
runs_on: '可运行在',
|
runs_on: '可运行在',
|
||||||
credit_hint: '按 Enter 键充值账户',
|
credit_hint: '按 Enter 键充值账户',
|
||||||
credit_label: '{denomination} 充值',
|
credit_label: '%{denomination} 充值',
|
||||||
paste: '粘贴',
|
paste: '粘贴',
|
||||||
paste_from_clipboard: '从剪贴板粘贴',
|
paste_from_clipboard: '从剪贴板粘贴',
|
||||||
paste_request: '粘贴请求',
|
paste_request: '粘贴请求',
|
||||||
@@ -146,8 +146,8 @@ window.localisation.cn = {
|
|||||||
expiry: '过期时间',
|
expiry: '过期时间',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: '付款证明',
|
payment_proof: '付款证明',
|
||||||
update_available: '更新{version}可用!',
|
update_available: '更新%{version}可用!',
|
||||||
latest_update: '您当前使用的是最新版本{version}。',
|
latest_update: '您当前使用的是最新版本%{version}。',
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
no_notifications: '没有通知',
|
no_notifications: '没有通知',
|
||||||
notifications_disabled: 'LNbits状态通知已禁用。',
|
notifications_disabled: 'LNbits状态通知已禁用。',
|
||||||
@@ -235,8 +235,8 @@ window.localisation.cn = {
|
|||||||
look_and_feel: '外观和感觉',
|
look_and_feel: '外观和感觉',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
color_scheme: '配色方案',
|
color_scheme: '配色方案',
|
||||||
extension_cost: '此版本需要支付最低 {cost} sats。',
|
extension_cost: '此版本需要支付最低 %{cost} sats。',
|
||||||
extension_paid_sats: '您已经支付了{paid_sats} sats。',
|
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
|
||||||
release_details_error: '无法获取发布详情。',
|
release_details_error: '无法获取发布详情。',
|
||||||
pay_from_wallet: '从钱包支付',
|
pay_from_wallet: '从钱包支付',
|
||||||
show_qr: '显示QR码',
|
show_qr: '显示QR码',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.cs = {
|
|||||||
reset_defaults: 'Obnovit výchozí',
|
reset_defaults: 'Obnovit výchozí',
|
||||||
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
||||||
download_backup: 'Stáhnout zálohu databáze',
|
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:
|
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 *',
|
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network 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',
|
view_github: 'Zobrazit na GitHubu',
|
||||||
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
|
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'POUŽÍVEJTE S OBEZŘETNOSTÍ - {name} peněženka je stále v BETĚ',
|
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ',
|
||||||
service_fee: 'Servisný poplatek: {amount} % za transakci',
|
service_fee: 'Servisný poplatek: %{amount} % za transakci',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Servisný poplatek: {amount} % za transakci (max {max} satoshi)',
|
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
||||||
toggle_darkmode: 'Přepnout tmavý režim',
|
toggle_darkmode: 'Přepnout tmavý režim',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.cs = {
|
|||||||
lnbits_version: 'Verze LNbits',
|
lnbits_version: 'Verze LNbits',
|
||||||
runs_on: 'Běží na',
|
runs_on: 'Běží na',
|
||||||
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
||||||
credit_label: '{denomination} k připsání',
|
credit_label: '%{denomination} k připsání',
|
||||||
paste: 'Vložit',
|
paste: 'Vložit',
|
||||||
paste_from_clipboard: 'Vložit ze schránky',
|
paste_from_clipboard: 'Vložit ze schránky',
|
||||||
paste_request: 'Vložit požadavek',
|
paste_request: 'Vložit požadavek',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.cs = {
|
|||||||
expiry: 'Expirace',
|
expiry: 'Expirace',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Důkaz platby',
|
payment_proof: 'Důkaz platby',
|
||||||
update_available: 'Dostupná aktualizace {version}!',
|
update_available: 'Dostupná aktualizace %{version}!',
|
||||||
latest_update: 'Máte nejnovější verzi {version}.',
|
latest_update: 'Máte nejnovější verzi %{version}.',
|
||||||
notifications: 'Notifikace',
|
notifications: 'Notifikace',
|
||||||
no_notifications: 'Žádné notifikace',
|
no_notifications: 'Žádné notifikace',
|
||||||
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
|
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
|
||||||
@@ -244,8 +244,8 @@ window.localisation.cs = {
|
|||||||
look_and_feel: 'Vzhled a chování',
|
look_and_feel: 'Vzhled a chování',
|
||||||
language: 'Jazyk',
|
language: 'Jazyk',
|
||||||
color_scheme: 'Barevné schéma',
|
color_scheme: 'Barevné schéma',
|
||||||
extension_cost: 'Toto vydání vyžaduje minimální platbu {cost} satoshi.',
|
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
|
||||||
extension_paid_sats: 'Již jste zaplatili {paid_sats} sats.',
|
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
|
||||||
release_details_error: 'Nelze získat podrobnosti o vydání.',
|
release_details_error: 'Nelze získat podrobnosti o vydání.',
|
||||||
pay_from_wallet: 'Platit z peněženky',
|
pay_from_wallet: 'Platit z peněženky',
|
||||||
show_qr: 'Zobrazit QR',
|
show_qr: 'Zobrazit QR',
|
||||||
|
|||||||
+12
-10
@@ -35,9 +35,9 @@ window.localisation.de = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
|
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
|
||||||
download_backup: 'Datenbank-Backup herunterladen',
|
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:
|
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:
|
paste_invoice_label:
|
||||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
@@ -59,9 +59,11 @@ window.localisation.de = {
|
|||||||
donate: 'Spenden',
|
donate: 'Spenden',
|
||||||
view_github: 'Auf GitHub anzeigen',
|
view_github: 'Auf GitHub anzeigen',
|
||||||
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
|
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
|
||||||
use_with_caution: 'BITTE MIT VORSICHT BENUTZEN - {name} Wallet ist noch BETA',
|
use_with_caution:
|
||||||
service_fee: 'Dienstleistungsgebühr: {amount} % pro Transaktion',
|
'BITTE MIT VORSICHT BENUTZEN - %{name} Wallet ist noch BETA',
|
||||||
service_fee_max: 'Servicegebühr: {amount} % pro Transaktion (max {max} Sats)',
|
service_fee: 'Dienstleistungsgebühr: %{amount} % pro Transaktion',
|
||||||
|
service_fee_max:
|
||||||
|
'Servicegebühr: %{amount} % pro Transaktion (max %{max} Sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
|
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
|
||||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||||
@@ -72,7 +74,7 @@ window.localisation.de = {
|
|||||||
lnbits_version: 'LNbits-Version',
|
lnbits_version: 'LNbits-Version',
|
||||||
runs_on: 'Läuft auf',
|
runs_on: 'Läuft auf',
|
||||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||||
credit_label: '{denomination} zu belasten',
|
credit_label: '%{denomination} zu belasten',
|
||||||
paste: 'Einfügen',
|
paste: 'Einfügen',
|
||||||
paste_from_clipboard: 'Einfügen aus der Zwischenablage',
|
paste_from_clipboard: 'Einfügen aus der Zwischenablage',
|
||||||
paste_request: 'Anfrage einfügen',
|
paste_request: 'Anfrage einfügen',
|
||||||
@@ -156,8 +158,8 @@ window.localisation.de = {
|
|||||||
expiry: 'Ablauf',
|
expiry: 'Ablauf',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Beleg',
|
payment_proof: 'Beleg',
|
||||||
update_available: 'Aktualisierung {version} verfügbar!',
|
update_available: 'Aktualisierung %{version} verfügbar!',
|
||||||
latest_update: 'Sie sind auf der neuesten Version {version}.',
|
latest_update: 'Sie sind auf der neuesten Version %{version}.',
|
||||||
notifications: 'Benachrichtigungen',
|
notifications: 'Benachrichtigungen',
|
||||||
no_notifications: 'Keine Benachrichtigungen',
|
no_notifications: 'Keine Benachrichtigungen',
|
||||||
notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.',
|
notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.',
|
||||||
@@ -250,8 +252,8 @@ window.localisation.de = {
|
|||||||
language: 'Sprache',
|
language: 'Sprache',
|
||||||
color_scheme: 'Farbschema',
|
color_scheme: 'Farbschema',
|
||||||
extension_cost:
|
extension_cost:
|
||||||
'Diese Version erfordert eine Zahlung von mindestens {cost} Sats.',
|
'Diese Version erfordert eine Zahlung von mindestens %{cost} Sats.',
|
||||||
extension_paid_sats: 'Sie haben bereits {paid_sats} Sats bezahlt.',
|
extension_paid_sats: 'Sie haben bereits %{paid_sats} Sats bezahlt.',
|
||||||
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
|
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
|
||||||
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
|
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
|
||||||
show_qr: 'QR anzeigen',
|
show_qr: 'QR anzeigen',
|
||||||
|
|||||||
+13
-19
@@ -34,9 +34,9 @@ window.localisation.en = {
|
|||||||
reset_defaults: 'Reset to defaults',
|
reset_defaults: 'Reset to defaults',
|
||||||
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
|
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
|
||||||
download_backup: 'Download database backup',
|
download_backup: 'Download database backup',
|
||||||
name_your_wallet: 'Name your {name} wallet',
|
name_your_wallet: 'Name your %{name} wallet',
|
||||||
wallet_topup_ok:
|
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 *',
|
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source 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',
|
donate: 'Donate',
|
||||||
view_github: 'View on GitHub',
|
view_github: 'View on GitHub',
|
||||||
voidwallet_active: 'VoidWallet is active! Payments disabled',
|
voidwallet_active: 'VoidWallet is active! Payments disabled',
|
||||||
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
|
use_with_caution: 'USE WITH CAUTION - %{name} wallet is still in BETA',
|
||||||
service_fee: 'Service fee: {amount} % per transaction',
|
service_fee: 'Service fee: %{amount} % per transaction',
|
||||||
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
|
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||||
toggle_darkmode: 'Toggle Dark Mode',
|
toggle_darkmode: 'Toggle Dark Mode',
|
||||||
@@ -70,7 +70,7 @@ window.localisation.en = {
|
|||||||
lnbits_version: 'LNbits version',
|
lnbits_version: 'LNbits version',
|
||||||
runs_on: 'Runs on',
|
runs_on: 'Runs on',
|
||||||
credit_hint: 'Press Enter to credit account',
|
credit_hint: 'Press Enter to credit account',
|
||||||
credit_label: '{denomination} to credit',
|
credit_label: '%{denomination} to credit',
|
||||||
paste: 'Paste',
|
paste: 'Paste',
|
||||||
paste_from_clipboard: 'Paste from clipboard',
|
paste_from_clipboard: 'Paste from clipboard',
|
||||||
paste_request: 'Paste Request',
|
paste_request: 'Paste Request',
|
||||||
@@ -152,8 +152,8 @@ window.localisation.en = {
|
|||||||
expiry: 'Expiry',
|
expiry: 'Expiry',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Payment Proof',
|
payment_proof: 'Payment Proof',
|
||||||
update_available: 'Update {version} available!',
|
update_available: 'Update %{version} available!',
|
||||||
latest_update: 'You are on the latest version {version}.',
|
latest_update: 'You are on the latest version %{version}.',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
no_notifications: 'No notifications',
|
no_notifications: 'No notifications',
|
||||||
notifications_disabled: 'LNbits status notifications are disabled.',
|
notifications_disabled: 'LNbits status notifications are disabled.',
|
||||||
@@ -194,7 +194,7 @@ window.localisation.en = {
|
|||||||
rate_limiter: 'Rate Limiter',
|
rate_limiter: 'Rate Limiter',
|
||||||
wallet_limiter: 'Wallet Limiter',
|
wallet_limiter: 'Wallet Limiter',
|
||||||
wallet_limit_max_withdraw_per_day:
|
wallet_limit_max_withdraw_per_day:
|
||||||
'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
|
'Max daily wallet withdrawal in sats (0 to disable)',
|
||||||
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
|
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
|
||||||
wallet_limit_secs_between_trans:
|
wallet_limit_secs_between_trans:
|
||||||
'Min secs between transactions per wallet (0 to disable)',
|
'Min secs between transactions per wallet (0 to disable)',
|
||||||
@@ -214,7 +214,6 @@ window.localisation.en = {
|
|||||||
login_to_account: 'Login to your account',
|
login_to_account: 'Login to your account',
|
||||||
create_account: 'Create account',
|
create_account: 'Create account',
|
||||||
account_settings: 'Account Settings',
|
account_settings: 'Account Settings',
|
||||||
signin_with_nostr: 'Continue with Nostr',
|
|
||||||
signin_with_google: 'Sign in with Google',
|
signin_with_google: 'Sign in with Google',
|
||||||
signin_with_github: 'Sign in with GitHub',
|
signin_with_github: 'Sign in with GitHub',
|
||||||
signin_with_keycloak: 'Sign in with Keycloak',
|
signin_with_keycloak: 'Sign in with Keycloak',
|
||||||
@@ -223,14 +222,11 @@ window.localisation.en = {
|
|||||||
password_config: 'Password Config',
|
password_config: 'Password Config',
|
||||||
password_repeat: 'Password repeat',
|
password_repeat: 'Password repeat',
|
||||||
change_password: 'Change Password',
|
change_password: 'Change Password',
|
||||||
update_credentials: 'Update Credentials',
|
|
||||||
update_pubkey: 'Update Public Key',
|
|
||||||
set_password: 'Set Password',
|
set_password: 'Set Password',
|
||||||
invalid_password: 'Password must have at least 8 characters',
|
invalid_password: 'Password must have at least 8 characters',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
register: 'Register',
|
register: 'Register',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
pubkey: 'Public Key',
|
|
||||||
user_id: 'User ID',
|
user_id: 'User ID',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
first_name: 'First Name',
|
first_name: 'First Name',
|
||||||
@@ -249,8 +245,8 @@ window.localisation.en = {
|
|||||||
gradient_background: 'Gradient Background',
|
gradient_background: 'Gradient Background',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
color_scheme: 'Color Scheme',
|
color_scheme: 'Color Scheme',
|
||||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
|
||||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
|
||||||
release_details_error: 'Cannot get the release details.',
|
release_details_error: 'Cannot get the release details.',
|
||||||
pay_from_wallet: 'Pay from Wallet',
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
wallet_required: 'Wallet *',
|
wallet_required: 'Wallet *',
|
||||||
@@ -262,11 +258,9 @@ window.localisation.en = {
|
|||||||
sell: 'Sell',
|
sell: 'Sell',
|
||||||
sell_require: 'Ask payment to enable extension',
|
sell_require: 'Ask payment to enable extension',
|
||||||
sell_info:
|
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',
|
hide_empty_wallets: 'Hide empty wallets',
|
||||||
recheck: 'Recheck',
|
recheck: 'Recheck',
|
||||||
contributors: 'Contributors',
|
contributors: 'Contributors',
|
||||||
license: 'License',
|
license: 'License'
|
||||||
reset_key: 'Reset Key',
|
|
||||||
reset_password: 'Reset Password'
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -35,9 +35,9 @@ window.localisation.es = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
|
'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
|
||||||
download_backup: 'Descargar copia de seguridad de la base de datos',
|
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:
|
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í',
|
paste_invoice_label: 'Pegue la factura aquí',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning 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',
|
donate: 'Donar',
|
||||||
view_github: 'Ver en GitHub',
|
view_github: 'Ver en GitHub',
|
||||||
voidwallet_active: '¡VoidWallet está activo! Pagos desactivados',
|
voidwallet_active: '¡VoidWallet está activo! Pagos desactivados',
|
||||||
use_with_caution: 'USAR CON CUIDADO - {name} Wallet aún está en BETA',
|
use_with_caution: 'USAR CON CUIDADO - %{name} Wallet aún está en BETA',
|
||||||
service_fee: 'Tarifa de servicio: {amount} % por transacción',
|
service_fee: 'Tarifa de servicio: %{amount} % por transacción',
|
||||||
service_fee_max:
|
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:
|
service_fee_tooltip:
|
||||||
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
|
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
|
||||||
toggle_darkmode: 'Cambiar modo oscuro',
|
toggle_darkmode: 'Cambiar modo oscuro',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.es = {
|
|||||||
lnbits_version: 'Versión de LNbits',
|
lnbits_version: 'Versión de LNbits',
|
||||||
runs_on: 'Corre en',
|
runs_on: 'Corre en',
|
||||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||||
credit_label: 'Cargar {denomination}',
|
credit_label: 'Cargar %{denomination}',
|
||||||
paste: 'Pegar',
|
paste: 'Pegar',
|
||||||
paste_from_clipboard: 'Pegar desde el portapapeles',
|
paste_from_clipboard: 'Pegar desde el portapapeles',
|
||||||
paste_request: 'Pegar solicitud',
|
paste_request: 'Pegar solicitud',
|
||||||
@@ -155,8 +155,8 @@ window.localisation.es = {
|
|||||||
expiry: 'Expiración',
|
expiry: 'Expiración',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Prueba de pago',
|
payment_proof: 'Prueba de pago',
|
||||||
update_available: '¡Actualización {version} disponible!',
|
update_available: '¡Actualización %{version} disponible!',
|
||||||
latest_update: 'Usted está en la última versión {version}.',
|
latest_update: 'Usted está en la última versión %{version}.',
|
||||||
notifications: 'Notificaciones',
|
notifications: 'Notificaciones',
|
||||||
no_notifications: 'No hay notificaciones',
|
no_notifications: 'No hay notificaciones',
|
||||||
notifications_disabled:
|
notifications_disabled:
|
||||||
@@ -249,8 +249,8 @@ window.localisation.es = {
|
|||||||
look_and_feel: 'Apariencia',
|
look_and_feel: 'Apariencia',
|
||||||
language: 'Idioma',
|
language: 'Idioma',
|
||||||
color_scheme: 'Esquema de colores',
|
color_scheme: 'Esquema de colores',
|
||||||
extension_cost: 'Esta versión requiere un pago mínimo de {cost} sats.',
|
extension_cost: 'Esta versión requiere un pago mínimo de %{cost} sats.',
|
||||||
extension_paid_sats: 'Ya has pagado {paid_sats} sats.',
|
extension_paid_sats: 'Ya has pagado %{paid_sats} sats.',
|
||||||
release_details_error: 'No se pueden obtener los detalles de la versión.',
|
release_details_error: 'No se pueden obtener los detalles de la versión.',
|
||||||
pay_from_wallet: 'Pagar desde la billetera',
|
pay_from_wallet: 'Pagar desde la billetera',
|
||||||
show_qr: 'Mostrar QR',
|
show_qr: 'Mostrar QR',
|
||||||
|
|||||||
+11
-10
@@ -35,9 +35,9 @@ window.localisation.fi = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
|
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
|
||||||
download_backup: 'Lataa tietokannan varmuuskopio',
|
download_backup: 'Lataa tietokannan varmuuskopio',
|
||||||
name_your_wallet: 'Anna {name}-lompakollesi nimi',
|
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
|
||||||
wallet_topup_ok:
|
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:
|
paste_invoice_label:
|
||||||
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
@@ -61,9 +61,10 @@ window.localisation.fi = {
|
|||||||
voidwallet_active:
|
voidwallet_active:
|
||||||
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
|
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: {name}',
|
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: %{name}',
|
||||||
service_fee: 'Palvelumaksu: {amount} % tapahtumasta',
|
service_fee: 'Palvelumaksu: %{amount} % tapahtumasta',
|
||||||
service_fee_max: 'Palvelumaksu: {amount} % tapahtumasta (enintään {max} sat)',
|
service_fee_max:
|
||||||
|
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
||||||
toggle_darkmode: 'Tumma näkymä',
|
toggle_darkmode: 'Tumma näkymä',
|
||||||
@@ -74,7 +75,7 @@ window.localisation.fi = {
|
|||||||
lnbits_version: 'LNbits versio',
|
lnbits_version: 'LNbits versio',
|
||||||
runs_on: 'Mukana menossa',
|
runs_on: 'Mukana menossa',
|
||||||
credit_hint: 'Hyväksy painamalla Enter',
|
credit_hint: 'Hyväksy painamalla Enter',
|
||||||
credit_label: 'Lisää tilille varoja {denomination}',
|
credit_label: 'Lisää tilille varoja %{denomination}',
|
||||||
paste: 'Liitä',
|
paste: 'Liitä',
|
||||||
paste_from_clipboard: 'Liitä leikepöydältä',
|
paste_from_clipboard: 'Liitä leikepöydältä',
|
||||||
paste_request: 'Liitä pyyntö',
|
paste_request: 'Liitä pyyntö',
|
||||||
@@ -154,9 +155,9 @@ window.localisation.fi = {
|
|||||||
expiry: 'Vanheneminen',
|
expiry: 'Vanheneminen',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Maksun varmenne',
|
payment_proof: 'Maksun varmenne',
|
||||||
update_available: 'Saatavilla on päivitys versioon {version}!',
|
update_available: 'Saatavilla on päivitys versioon %{version}!',
|
||||||
latest_update:
|
latest_update:
|
||||||
'Käytössä oleva versio {version}, on viimeisin saatavilla oleva.',
|
'Käytössä oleva versio %{version}, on viimeisin saatavilla oleva.',
|
||||||
notifications: 'Tiedotteet',
|
notifications: 'Tiedotteet',
|
||||||
no_notifications: 'Ei tiedotteita',
|
no_notifications: 'Ei tiedotteita',
|
||||||
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
|
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
|
||||||
@@ -246,8 +247,8 @@ window.localisation.fi = {
|
|||||||
look_and_feel: 'Kieli ja värit',
|
look_and_feel: 'Kieli ja värit',
|
||||||
language: 'Kieli',
|
language: 'Kieli',
|
||||||
color_scheme: 'Väriteema',
|
color_scheme: 'Väriteema',
|
||||||
extension_cost: 'Tämä julkaisu edellyttää vähintään {cost} satsin maksua.',
|
extension_cost: 'Tämä julkaisu edellyttää vähintään %{cost} satsin maksua.',
|
||||||
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
|
extension_paid_sats: 'Olet jo maksanut %{paid_sats} satsia.',
|
||||||
release_details_error: 'Ei voi hakea julkaisun tietoja.',
|
release_details_error: 'Ei voi hakea julkaisun tietoja.',
|
||||||
pay_from_wallet: 'Maksa lompakosta',
|
pay_from_wallet: 'Maksa lompakosta',
|
||||||
show_qr: 'Näytä QR',
|
show_qr: 'Näytä QR',
|
||||||
|
|||||||
+11
-10
@@ -37,9 +37,9 @@ window.localisation.fr = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
|
'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',
|
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:
|
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:
|
paste_invoice_label:
|
||||||
'Coller une facture, une demande de paiement ou un code lnurl *',
|
'Coller une facture, une demande de paiement ou un code lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
@@ -62,10 +62,10 @@ window.localisation.fr = {
|
|||||||
view_github: 'Voir sur GitHub',
|
view_github: 'Voir sur GitHub',
|
||||||
voidwallet_active: 'VoidWallet est actif! Paiements désactivés',
|
voidwallet_active: 'VoidWallet est actif! Paiements désactivés',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'UTILISER AVEC PRUDENCE - Le portefeuille {name} est toujours en version BETA',
|
'UTILISER AVEC PRUDENCE - Le portefeuille %{name} est toujours en version BETA',
|
||||||
service_fee: 'Frais de service : {amount} % par transaction',
|
service_fee: 'Frais de service : %{amount} % par transaction',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Frais de service : {amount} % par transaction (max {max} sats)',
|
'Frais de service : %{amount} % par transaction (max %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
|
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
|
||||||
toggle_darkmode: 'Basculer le mode sombre',
|
toggle_darkmode: 'Basculer le mode sombre',
|
||||||
@@ -76,7 +76,7 @@ window.localisation.fr = {
|
|||||||
lnbits_version: 'Version de LNbits',
|
lnbits_version: 'Version de LNbits',
|
||||||
runs_on: 'Fonctionne sur',
|
runs_on: 'Fonctionne sur',
|
||||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||||
credit_label: '{denomination} à créditer',
|
credit_label: '%{denomination} à créditer',
|
||||||
paste: 'Coller',
|
paste: 'Coller',
|
||||||
paste_from_clipboard: 'Coller depuis le presse-papiers',
|
paste_from_clipboard: 'Coller depuis le presse-papiers',
|
||||||
paste_request: 'Coller la requête',
|
paste_request: 'Coller la requête',
|
||||||
@@ -159,8 +159,8 @@ window.localisation.fr = {
|
|||||||
expiry: 'Expiration',
|
expiry: 'Expiration',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Preuve de paiement',
|
payment_proof: 'Preuve de paiement',
|
||||||
update_available: 'Mise à jour {version} disponible !',
|
update_available: 'Mise à jour %{version} disponible !',
|
||||||
latest_update: 'Vous êtes sur la dernière version {version}.',
|
latest_update: 'Vous êtes sur la dernière version %{version}.',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
no_notifications: 'Aucune notification',
|
no_notifications: 'Aucune notification',
|
||||||
notifications_disabled:
|
notifications_disabled:
|
||||||
@@ -253,8 +253,9 @@ window.localisation.fr = {
|
|||||||
look_and_feel: 'Apparence',
|
look_and_feel: 'Apparence',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
color_scheme: 'Schéma de couleurs',
|
color_scheme: 'Schéma de couleurs',
|
||||||
extension_cost: 'Cette version nécessite un paiement minimum de {cost} sats.',
|
extension_cost:
|
||||||
extension_paid_sats: 'Vous avez déjà payé {paid_sats} sats.',
|
'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.",
|
release_details_error: "Impossible d'obtenir les détails de la version.",
|
||||||
pay_from_wallet: 'Payer depuis le portefeuille',
|
pay_from_wallet: 'Payer depuis le portefeuille',
|
||||||
show_qr: 'Afficher le QR',
|
show_qr: 'Afficher le QR',
|
||||||
|
|||||||
+10
-10
@@ -35,9 +35,9 @@ window.localisation.it = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Cancella tutte le impostazioni e ripristina i valori predefiniti',
|
'Cancella tutte le impostazioni e ripristina i valori predefiniti',
|
||||||
download_backup: 'Scarica il backup del database',
|
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:
|
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:
|
paste_invoice_label:
|
||||||
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
@@ -59,10 +59,10 @@ window.localisation.it = {
|
|||||||
donate: 'Donazioni',
|
donate: 'Donazioni',
|
||||||
view_github: 'Visualizza su GitHub',
|
view_github: 'Visualizza su GitHub',
|
||||||
voidwallet_active: 'VoidWallet è attivo! Pagamenti disabilitati',
|
voidwallet_active: 'VoidWallet è attivo! Pagamenti disabilitati',
|
||||||
use_with_caution: 'USARE CON CAUTELA - {name} portafoglio è ancora in BETA',
|
use_with_caution: 'USARE CON CAUTELA - %{name} portafoglio è ancora in BETA',
|
||||||
service_fee: 'Commissione di servizio: {amount} % per transazione',
|
service_fee: 'Commissione di servizio: %{amount} % per transazione',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Commissione di servizio: {amount} % per transazione (max {max} sats)',
|
'Commissione di servizio: %{amount} % per transazione (max %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
|
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
|
||||||
toggle_darkmode: 'Attiva la modalità notturna',
|
toggle_darkmode: 'Attiva la modalità notturna',
|
||||||
@@ -73,7 +73,7 @@ window.localisation.it = {
|
|||||||
lnbits_version: 'Versione di LNbits',
|
lnbits_version: 'Versione di LNbits',
|
||||||
runs_on: 'Esegue su',
|
runs_on: 'Esegue su',
|
||||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||||
credit_label: '{denomination} da accreditare',
|
credit_label: '%{denomination} da accreditare',
|
||||||
paste: 'Incolla',
|
paste: 'Incolla',
|
||||||
paste_from_clipboard: 'Incolla dagli appunti',
|
paste_from_clipboard: 'Incolla dagli appunti',
|
||||||
paste_request: 'Richiesta di pagamento',
|
paste_request: 'Richiesta di pagamento',
|
||||||
@@ -156,8 +156,8 @@ window.localisation.it = {
|
|||||||
expiry: 'Scadenza',
|
expiry: 'Scadenza',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Prova di pagamento',
|
payment_proof: 'Prova di pagamento',
|
||||||
update_available: 'Aggiornamento {version} disponibile!',
|
update_available: 'Aggiornamento %{version} disponibile!',
|
||||||
latest_update: 'Sei sulla versione più recente {version}.',
|
latest_update: 'Sei sulla versione più recente %{version}.',
|
||||||
notifications: 'Notifiche',
|
notifications: 'Notifiche',
|
||||||
no_notifications: 'Nessuna notifica',
|
no_notifications: 'Nessuna notifica',
|
||||||
notifications_disabled: 'Le notifiche di stato di LNbits sono disattivate.',
|
notifications_disabled: 'Le notifiche di stato di LNbits sono disattivate.',
|
||||||
@@ -251,8 +251,8 @@ window.localisation.it = {
|
|||||||
language: 'Lingua',
|
language: 'Lingua',
|
||||||
color_scheme: 'Schema dei colori',
|
color_scheme: 'Schema dei colori',
|
||||||
extension_cost:
|
extension_cost:
|
||||||
'Questa versione richiede un pagamento minimo di {cost} satoshi.',
|
'Questa versione richiede un pagamento minimo di %{cost} satoshi.',
|
||||||
extension_paid_sats: 'Hai già pagato {paid_sats} sats.',
|
extension_paid_sats: 'Hai già pagato %{paid_sats} sats.',
|
||||||
release_details_error: 'Impossibile ottenere i dettagli della versione.',
|
release_details_error: 'Impossibile ottenere i dettagli della versione.',
|
||||||
pay_from_wallet: 'Paga dal Portafoglio',
|
pay_from_wallet: 'Paga dal Portafoglio',
|
||||||
show_qr: 'Mostra QR',
|
show_qr: 'Mostra QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.jp = {
|
|||||||
reset_defaults: 'リセット',
|
reset_defaults: 'リセット',
|
||||||
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
|
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
|
||||||
download_backup: 'データベースのバックアップをダウンロードする',
|
download_backup: 'データベースのバックアップをダウンロードする',
|
||||||
name_your_wallet: 'あなたのウォレットの名前 {name}',
|
name_your_wallet: 'あなたのウォレットの名前 %{name}',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'仮想資金の作成に成功しました({amount} sats)。支払いは資金ソースの実際の資金に依存します。',
|
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。',
|
||||||
paste_invoice_label: '請求書を貼り付けてください',
|
paste_invoice_label: '請求書を貼り付けてください',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||||
@@ -58,9 +58,9 @@ window.localisation.jp = {
|
|||||||
view_github: 'GitHubで表示',
|
view_github: 'GitHubで表示',
|
||||||
voidwallet_active: 'Voidwalletアクティブ',
|
voidwallet_active: 'Voidwalletアクティブ',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'注意して使用してください - {name} ウォレットはまだベータ版です',
|
'注意して使用してください - %{name} ウォレットはまだベータ版です',
|
||||||
service_fee: '取引ごとのサービス手数料: {amount} %',
|
service_fee: '取引ごとのサービス手数料: %{amount} %',
|
||||||
service_fee_max: '取引手数料:{amount}%(最大{max}サトシ)',
|
service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)',
|
||||||
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
|
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
|
||||||
toggle_darkmode: 'ダークモードを切り替える',
|
toggle_darkmode: 'ダークモードを切り替える',
|
||||||
payment_reactions: '支払いの反応',
|
payment_reactions: '支払いの反応',
|
||||||
@@ -71,7 +71,7 @@ window.localisation.jp = {
|
|||||||
runs_on: 'で実行',
|
runs_on: 'で実行',
|
||||||
credit_hint:
|
credit_hint:
|
||||||
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
|
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
|
||||||
credit_label: '{denomination} をクレジットに',
|
credit_label: '%{denomination} をクレジットに',
|
||||||
paste: '貼り付け',
|
paste: '貼り付け',
|
||||||
paste_from_clipboard: 'クリップボードから貼り付け',
|
paste_from_clipboard: 'クリップボードから貼り付け',
|
||||||
paste_request: 'リクエストを貼り付ける',
|
paste_request: 'リクエストを貼り付ける',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.jp = {
|
|||||||
expiry: '有効期限',
|
expiry: '有効期限',
|
||||||
webhook: 'ウェブフック',
|
webhook: 'ウェブフック',
|
||||||
payment_proof: '支払い証明',
|
payment_proof: '支払い証明',
|
||||||
update_available: 'アップデート{version}が利用可能です!',
|
update_available: 'アップデート%{version}が利用可能です!',
|
||||||
latest_update: 'あなたは最新バージョン{version}を使用しています。',
|
latest_update: 'あなたは最新バージョン%{version}を使用しています。',
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
no_notifications: '通知はありません',
|
no_notifications: '通知はありません',
|
||||||
notifications_disabled: 'LNbitsステータス通知は無効です。',
|
notifications_disabled: 'LNbitsステータス通知は無効です。',
|
||||||
@@ -246,8 +246,8 @@ window.localisation.jp = {
|
|||||||
look_and_feel: 'ルック・アンド・フィール',
|
look_and_feel: 'ルック・アンド・フィール',
|
||||||
language: '言語',
|
language: '言語',
|
||||||
color_scheme: 'カラースキーム',
|
color_scheme: 'カラースキーム',
|
||||||
extension_cost: 'このリリースには最低 {cost} サトシの支払いが必要です。',
|
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。',
|
||||||
extension_paid_sats: 'すでに{paid_sats} satsを支払いました。',
|
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。',
|
||||||
release_details_error: 'リリースの詳細を取得できません。',
|
release_details_error: 'リリースの詳細を取得できません。',
|
||||||
pay_from_wallet: 'ウォレットから支払う',
|
pay_from_wallet: 'ウォレットから支払う',
|
||||||
show_qr: 'QRを表示',
|
show_qr: 'QRを表示',
|
||||||
|
|||||||
+10
-10
@@ -35,9 +35,9 @@ window.localisation.kr = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
|
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
|
||||||
download_backup: '데이터베이스 백업 다운로드',
|
download_backup: '데이터베이스 백업 다운로드',
|
||||||
name_your_wallet: '사용할 {name}지갑의 이름을 정하세요',
|
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'성공적으로 가상 자금을 생성했습니다 ({amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
|
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
|
||||||
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
|
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
'설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||||
@@ -58,9 +58,9 @@ window.localisation.kr = {
|
|||||||
donate: '기부',
|
donate: '기부',
|
||||||
view_github: 'GitHub 페이지 보기',
|
view_github: 'GitHub 페이지 보기',
|
||||||
voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.',
|
voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.',
|
||||||
use_with_caution: '주의하세요 - {name} 지갑은 아직 BETA 단계입니다.',
|
use_with_caution: '주의하세요 - %{name} 지갑은 아직 BETA 단계입니다.',
|
||||||
service_fee: '서비스 수수료: 거래액의 {amount} %',
|
service_fee: '서비스 수수료: 거래액의 %{amount} %',
|
||||||
service_fee_max: '서비스 수수료: 거래액의 {amount} % (최대 {max} sats)',
|
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
|
'지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
|
||||||
toggle_darkmode: '다크 모드 전환',
|
toggle_darkmode: '다크 모드 전환',
|
||||||
@@ -71,7 +71,7 @@ window.localisation.kr = {
|
|||||||
lnbits_version: 'LNbits 버전',
|
lnbits_version: 'LNbits 버전',
|
||||||
runs_on: 'Runs on',
|
runs_on: 'Runs on',
|
||||||
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
|
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
|
||||||
credit_label: '{denomination} 단위로 충전하기',
|
credit_label: '%{denomination} 단위로 충전하기',
|
||||||
paste: '붙여넣기',
|
paste: '붙여넣기',
|
||||||
paste_from_clipboard: '클립보드에서 붙여넣기',
|
paste_from_clipboard: '클립보드에서 붙여넣기',
|
||||||
paste_request: '지불 요청 붙여넣기',
|
paste_request: '지불 요청 붙여넣기',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.kr = {
|
|||||||
expiry: '만료',
|
expiry: '만료',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Payment 증거',
|
payment_proof: 'Payment 증거',
|
||||||
update_available: '{version}으로 업데이트가 가능합니다.',
|
update_available: '%{version}으로 업데이트가 가능합니다.',
|
||||||
latest_update: '이미 {version} 버전으로 업데이트되었습니다.',
|
latest_update: '이미 %{version} 버전으로 업데이트되었습니다.',
|
||||||
notifications: '알림',
|
notifications: '알림',
|
||||||
no_notifications: '알림 없음',
|
no_notifications: '알림 없음',
|
||||||
notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.',
|
notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.',
|
||||||
@@ -243,8 +243,8 @@ window.localisation.kr = {
|
|||||||
look_and_feel: '외관과 느낌',
|
look_and_feel: '외관과 느낌',
|
||||||
language: '언어',
|
language: '언어',
|
||||||
color_scheme: '색상 구성',
|
color_scheme: '색상 구성',
|
||||||
extension_cost: '이 버전은 최소 {cost} sats의 지불이 필요합니다.',
|
extension_cost: '이 버전은 최소 %{cost} sats의 지불이 필요합니다.',
|
||||||
extension_paid_sats: '당신은 이미 {paid_sats} sats를 지불했습니다.',
|
extension_paid_sats: '당신은 이미 %{paid_sats} sats를 지불했습니다.',
|
||||||
release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.',
|
release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.',
|
||||||
pay_from_wallet: '지갑에서 결제하다',
|
pay_from_wallet: '지갑에서 결제하다',
|
||||||
show_qr: 'QR 보기',
|
show_qr: 'QR 보기',
|
||||||
|
|||||||
+12
-10
@@ -36,9 +36,9 @@ window.localisation.nl = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Wis alle instellingen en herstel de standaardinstellingen.',
|
'Wis alle instellingen en herstel de standaardinstellingen.',
|
||||||
download_backup: 'Databaseback-up downloaden',
|
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:
|
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*',
|
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien 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,9 +60,10 @@ window.localisation.nl = {
|
|||||||
view_github: 'Bekijken op GitHub',
|
view_github: 'Bekijken op GitHub',
|
||||||
voidwallet_active: 'VoidWallet is actief! Betalingen uitgeschakeld',
|
voidwallet_active: 'VoidWallet is actief! Betalingen uitgeschakeld',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'GEBRUIK MET VOORZICHTIGHEID - {name} portemonnee is nog in BETA',
|
'GEBRUIK MET VOORZICHTIGHEID - %{name} portemonnee is nog in BETA',
|
||||||
service_fee: 'Servicekosten: {amount} % per transactie',
|
service_fee: 'Servicekosten: %{amount} % per transactie',
|
||||||
service_fee_max: 'Servicekosten: {amount} % per transactie (max {max} sats)',
|
service_fee_max:
|
||||||
|
'Servicekosten: %{amount} % per transactie (max %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie',
|
'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie',
|
||||||
toggle_darkmode: 'Donkere modus aan/uit',
|
toggle_darkmode: 'Donkere modus aan/uit',
|
||||||
@@ -73,7 +74,7 @@ window.localisation.nl = {
|
|||||||
lnbits_version: 'LNbits-versie',
|
lnbits_version: 'LNbits-versie',
|
||||||
runs_on: 'Draait op',
|
runs_on: 'Draait op',
|
||||||
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
||||||
credit_label: '{denomination} te crediteren',
|
credit_label: '%{denomination} te crediteren',
|
||||||
paste: 'Plakken',
|
paste: 'Plakken',
|
||||||
paste_from_clipboard: 'Plakken van klembord',
|
paste_from_clipboard: 'Plakken van klembord',
|
||||||
paste_request: 'Verzoek plakken',
|
paste_request: 'Verzoek plakken',
|
||||||
@@ -155,8 +156,8 @@ window.localisation.nl = {
|
|||||||
expiry: 'Vervaldatum',
|
expiry: 'Vervaldatum',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Betalingsbewijs',
|
payment_proof: 'Betalingsbewijs',
|
||||||
update_available: 'Update {version} beschikbaar!',
|
update_available: 'Update %{version} beschikbaar!',
|
||||||
latest_update: 'U bent op de nieuwste versie {version}.',
|
latest_update: 'U bent op de nieuwste versie %{version}.',
|
||||||
notifications: 'Meldingen',
|
notifications: 'Meldingen',
|
||||||
no_notifications: 'Geen meldingen',
|
no_notifications: 'Geen meldingen',
|
||||||
notifications_disabled: 'LNbits-statusmeldingen zijn uitgeschakeld.',
|
notifications_disabled: 'LNbits-statusmeldingen zijn uitgeschakeld.',
|
||||||
@@ -248,8 +249,9 @@ window.localisation.nl = {
|
|||||||
look_and_feel: 'Uiterlijk en gedrag',
|
look_and_feel: 'Uiterlijk en gedrag',
|
||||||
language: 'Taal',
|
language: 'Taal',
|
||||||
color_scheme: 'Kleurenschema',
|
color_scheme: 'Kleurenschema',
|
||||||
extension_cost: 'Deze release vereist een betaling van minimaal {cost} sats.',
|
extension_cost:
|
||||||
extension_paid_sats: 'U heeft al {paid_sats} sats betaald.',
|
'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.',
|
release_details_error: 'Kan de gegevens van de release niet ophalen.',
|
||||||
pay_from_wallet: 'Betalen vanuit Portemonnee',
|
pay_from_wallet: 'Betalen vanuit Portemonnee',
|
||||||
show_qr: 'Toon QR',
|
show_qr: 'Toon QR',
|
||||||
|
|||||||
+12
-12
@@ -35,9 +35,9 @@ window.localisation.pi = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
|
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
|
||||||
download_backup: 'Download database booty',
|
download_backup: 'Download database booty',
|
||||||
name_your_wallet: 'Name yer {name} treasure chest',
|
name_your_wallet: 'Name yer %{name} treasure chest',
|
||||||
wallet_topup_ok:
|
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!',
|
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
|
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source 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',
|
view_github: 'View on GitHub and find treasures',
|
||||||
voidwallet_active: 'VoidWallet be active! Payments disabled',
|
voidwallet_active: 'VoidWallet be active! Payments disabled',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'USE WITH CAUTION - {name} chest be still in BETA. Aye, be careful!',
|
'USE WITH CAUTION - %{name} chest be still in BETA. Aye, be careful!',
|
||||||
service_fee: 'Service fee: {amount} % per transaction',
|
service_fee: 'Service fee: %{amount} % per transaction',
|
||||||
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
|
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
"Service fee charged by the LNbits server admin per goin' transaction",
|
"Service fee charged by the LNbits server admin per goin' transaction",
|
||||||
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.pi = {
|
|||||||
lnbits_version: 'LNbits version, arr!',
|
lnbits_version: 'LNbits version, arr!',
|
||||||
runs_on: 'Runs on, matey',
|
runs_on: 'Runs on, matey',
|
||||||
credit_hint: 'Press Enter to credit account and make it richer',
|
credit_hint: 'Press Enter to credit account and make it richer',
|
||||||
credit_label: '{denomination} to credit, arr!',
|
credit_label: '%{denomination} to credit, arr!',
|
||||||
paste: 'Stow',
|
paste: 'Stow',
|
||||||
paste_from_clipboard: 'Paste from clipboard',
|
paste_from_clipboard: 'Paste from clipboard',
|
||||||
paste_request: 'Paste Request and find treasures',
|
paste_request: 'Paste Request and find treasures',
|
||||||
@@ -155,8 +155,8 @@ window.localisation.pi = {
|
|||||||
expiry: 'Expiry like the food on a ship, ye landlubber',
|
expiry: 'Expiry like the food on a ship, ye landlubber',
|
||||||
webhook: 'Webhook like a fishing line, arrr',
|
webhook: 'Webhook like a fishing line, arrr',
|
||||||
payment_proof: 'Payment Proof like a seal of authenticity, argh',
|
payment_proof: 'Payment Proof like a seal of authenticity, argh',
|
||||||
update_available: 'Update {version} available, me matey!',
|
update_available: 'Update %{version} available, me matey!',
|
||||||
latest_update: "Ye be on th' latest version {version}.",
|
latest_update: "Ye be on th' latest version %{version}.",
|
||||||
notifications: 'Notificashuns',
|
notifications: 'Notificashuns',
|
||||||
no_notifications: "No noticin's",
|
no_notifications: "No noticin's",
|
||||||
notifications_disabled: 'LNbits status notifications be disabled, arr!',
|
notifications_disabled: 'LNbits status notifications be disabled, arr!',
|
||||||
@@ -197,7 +197,7 @@ window.localisation.pi = {
|
|||||||
rate_limiter: 'Rate Limiter',
|
rate_limiter: 'Rate Limiter',
|
||||||
wallet_limiter: 'Pouch Limitar',
|
wallet_limiter: 'Pouch Limitar',
|
||||||
wallet_limit_max_withdraw_per_day:
|
wallet_limit_max_withdraw_per_day:
|
||||||
'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
|
'Max daily wallet withdrawal in sats (0 ter disable)',
|
||||||
wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)',
|
wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)',
|
||||||
wallet_limit_secs_between_trans:
|
wallet_limit_secs_between_trans:
|
||||||
"Min secs 'tween transactions per wallet (0 to disable)",
|
"Min secs 'tween transactions per wallet (0 to disable)",
|
||||||
@@ -206,7 +206,7 @@ window.localisation.pi = {
|
|||||||
minute: 'minnit',
|
minute: 'minnit',
|
||||||
second: 'second',
|
second: 'second',
|
||||||
hour: 'hour',
|
hour: 'hour',
|
||||||
disable_server_log: "Disabl' {Server} Log",
|
disable_server_log: "Disabl' %{Server} Log",
|
||||||
enable_server_log: 'Enable Server Log',
|
enable_server_log: 'Enable Server Log',
|
||||||
coming_soon: "Feature comin' soon",
|
coming_soon: "Feature comin' soon",
|
||||||
session_has_expired: 'Yer session has expired. Please login again.',
|
session_has_expired: 'Yer session has expired. Please login again.',
|
||||||
@@ -247,8 +247,8 @@ window.localisation.pi = {
|
|||||||
language: 'Langwidge',
|
language: 'Langwidge',
|
||||||
color_scheme: 'Colour Scheme',
|
color_scheme: 'Colour Scheme',
|
||||||
extension_cost:
|
extension_cost:
|
||||||
"This release be needin' a payment o' minimum {cost} sats, arr.",
|
"This release be needin' a payment o' minimum %{cost} sats, arr.",
|
||||||
extension_paid_sats: 'Ye have already paid {paid_sats} sats.',
|
extension_paid_sats: 'Ye have already paid %{paid_sats} sats.',
|
||||||
release_details_error: "Cannot get th' release details.",
|
release_details_error: "Cannot get th' release details.",
|
||||||
pay_from_wallet: 'Pay from ye Wallet',
|
pay_from_wallet: 'Pay from ye Wallet',
|
||||||
show_qr: 'Show QR',
|
show_qr: 'Show QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.pl = {
|
|||||||
reset_defaults: 'Powrót do ustawień domyślnych',
|
reset_defaults: 'Powrót do ustawień domyślnych',
|
||||||
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
|
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
|
||||||
download_backup: 'Pobierz kopię zapasową bazy danych',
|
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:
|
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 *',
|
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning 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',
|
donate: 'Podaruj',
|
||||||
view_github: 'Otwórz GitHub',
|
view_github: 'Otwórz GitHub',
|
||||||
voidwallet_active: 'VoidWallet jest aktywny! Płatności są niemożliwe',
|
voidwallet_active: 'VoidWallet jest aktywny! Płatności są niemożliwe',
|
||||||
use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel {name} jest w wersji BETA',
|
use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel %{name} jest w wersji BETA',
|
||||||
service_fee: 'Opłata serwisowa: {amount} % za transakcję',
|
service_fee: 'Opłata serwisowa: %{amount} % za transakcję',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Opłata serwisowa: {amount} % za transakcję (maks {max} sat)',
|
'Opłata serwisowa: %{amount} % za transakcję (maks %{max} sat)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję',
|
'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję',
|
||||||
toggle_darkmode: 'Tryb nocny',
|
toggle_darkmode: 'Tryb nocny',
|
||||||
@@ -71,7 +71,7 @@ window.localisation.pl = {
|
|||||||
lnbits_version: 'Wersja LNbits',
|
lnbits_version: 'Wersja LNbits',
|
||||||
runs_on: 'Działa na',
|
runs_on: 'Działa na',
|
||||||
credit_hint: 'Naciśnij Enter aby doładować konto',
|
credit_hint: 'Naciśnij Enter aby doładować konto',
|
||||||
credit_label: '{denomination} doładowanie',
|
credit_label: '%{denomination} doładowanie',
|
||||||
paste: 'Wklej',
|
paste: 'Wklej',
|
||||||
paste_from_clipboard: 'Wklej ze schowka',
|
paste_from_clipboard: 'Wklej ze schowka',
|
||||||
paste_request: 'Wklej żądanie',
|
paste_request: 'Wklej żądanie',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.pl = {
|
|||||||
expiry: 'Wygasa',
|
expiry: 'Wygasa',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Potwierdzenie płatności',
|
payment_proof: 'Potwierdzenie płatności',
|
||||||
update_available: 'Aktualizacja {version} dostępna!',
|
update_available: 'Aktualizacja %{version} dostępna!',
|
||||||
latest_update: 'Korzystasz z najnowszej wersji {version}.',
|
latest_update: 'Korzystasz z najnowszej wersji %{version}.',
|
||||||
notifications: 'Powiadomienia',
|
notifications: 'Powiadomienia',
|
||||||
no_notifications: 'Brak powiadomień',
|
no_notifications: 'Brak powiadomień',
|
||||||
notifications_disabled: 'Powiadomienia o statusie LNbits są wyłączone.',
|
notifications_disabled: 'Powiadomienia o statusie LNbits są wyłączone.',
|
||||||
@@ -246,8 +246,8 @@ window.localisation.pl = {
|
|||||||
language: 'Język',
|
language: 'Język',
|
||||||
color_scheme: 'Schemat kolorów',
|
color_scheme: 'Schemat kolorów',
|
||||||
extension_cost:
|
extension_cost:
|
||||||
'To niniejsze wydanie wymaga zapłaty minimalnej {cost} satów.',
|
'To niniejsze wydanie wymaga zapłaty minimalnej %{cost} satów.',
|
||||||
extension_paid_sats: 'Już zapłaciłeś {paid_sats} satów.',
|
extension_paid_sats: 'Już zapłaciłeś %{paid_sats} satów.',
|
||||||
release_details_error: 'Nie można uzyskać szczegółów wydania.',
|
release_details_error: 'Nie można uzyskać szczegółów wydania.',
|
||||||
pay_from_wallet: 'Zapłać z portfela',
|
pay_from_wallet: 'Zapłać z portfela',
|
||||||
show_qr: 'Pokaż kod QR',
|
show_qr: 'Pokaż kod QR',
|
||||||
|
|||||||
+10
-10
@@ -35,9 +35,9 @@ window.localisation.pt = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Apagar todas as configurações e redefinir para os padrões.',
|
'Apagar todas as configurações e redefinir para os padrões.',
|
||||||
download_backup: 'Fazer backup da base de dados',
|
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:
|
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 *',
|
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network 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',
|
donate: 'Doar',
|
||||||
view_github: 'Ver no GitHub',
|
view_github: 'Ver no GitHub',
|
||||||
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
||||||
use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
|
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: 'Taxa de serviço: %{amount} % por transação',
|
||||||
service_fee_max:
|
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:
|
service_fee_tooltip:
|
||||||
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.pt = {
|
|||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
credit_label: '{denomination} para creditar',
|
credit_label: '%{denomination} para creditar',
|
||||||
paste: 'Colar',
|
paste: 'Colar',
|
||||||
paste_from_clipboard: 'Colar da área de transferência',
|
paste_from_clipboard: 'Colar da área de transferência',
|
||||||
paste_request: 'Colar Pedido',
|
paste_request: 'Colar Pedido',
|
||||||
@@ -154,8 +154,8 @@ window.localisation.pt = {
|
|||||||
expiry: 'Validade',
|
expiry: 'Validade',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Comprovativo de pagamento',
|
payment_proof: 'Comprovativo de pagamento',
|
||||||
update_available: 'Atualização {version} disponível!',
|
update_available: 'Atualização %{version} disponível!',
|
||||||
latest_update: 'Você está na última versão {version}.',
|
latest_update: 'Você está na última versão %{version}.',
|
||||||
notifications: 'Notificações',
|
notifications: 'Notificações',
|
||||||
no_notifications: 'Sem notificações',
|
no_notifications: 'Sem notificações',
|
||||||
notifications_disabled:
|
notifications_disabled:
|
||||||
@@ -246,8 +246,8 @@ window.localisation.pt = {
|
|||||||
look_and_feel: 'Aparência e Sensação',
|
look_and_feel: 'Aparência e Sensação',
|
||||||
language: 'Idioma',
|
language: 'Idioma',
|
||||||
color_scheme: 'Esquema de Cores',
|
color_scheme: 'Esquema de Cores',
|
||||||
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
|
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
||||||
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
|
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
|
||||||
release_details_error: 'Não é possível obter os detalhes da versão.',
|
release_details_error: 'Não é possível obter os detalhes da versão.',
|
||||||
pay_from_wallet: 'Pague da Carteira',
|
pay_from_wallet: 'Pague da Carteira',
|
||||||
show_qr: 'Exibir QR',
|
show_qr: 'Exibir QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.sk = {
|
|||||||
reset_defaults: 'Obnoviť predvolené',
|
reset_defaults: 'Obnoviť predvolené',
|
||||||
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
|
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
|
||||||
download_backup: 'Stiahnuť zálohu databázy',
|
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:
|
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 *',
|
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
|
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network 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ť',
|
donate: 'Prispieť',
|
||||||
view_github: 'Zobraziť na GitHube',
|
view_github: 'Zobraziť na GitHube',
|
||||||
voidwallet_active: 'VoidWallet je aktívny! Platby zakázané',
|
voidwallet_active: 'VoidWallet je aktívny! Platby zakázané',
|
||||||
use_with_caution: 'POUŽÍVAJTE OPATRNE - {name} peňaženka je stále v BETE',
|
use_with_caution: 'POUŽÍVAJTE OPATRNE - %{name} peňaženka je stále v BETE',
|
||||||
service_fee: 'Servisný poplatok: {amount} % za transakciu',
|
service_fee: 'Servisný poplatok: %{amount} % za transakciu',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Servisný poplatok: {amount} % za transakciu (max {max} satoshi)',
|
'Servisný poplatok: %{amount} % za transakciu (max %{max} satoshi)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu',
|
'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu',
|
||||||
toggle_darkmode: 'Prepnúť Tmavý režim',
|
toggle_darkmode: 'Prepnúť Tmavý režim',
|
||||||
@@ -71,7 +71,7 @@ window.localisation.sk = {
|
|||||||
lnbits_version: 'Verzia LNbits',
|
lnbits_version: 'Verzia LNbits',
|
||||||
runs_on: 'Beží na',
|
runs_on: 'Beží na',
|
||||||
credit_hint: 'Stlačte Enter pre pripísanie na účet',
|
credit_hint: 'Stlačte Enter pre pripísanie na účet',
|
||||||
credit_label: '{denomination} na pripísanie',
|
credit_label: '%{denomination} na pripísanie',
|
||||||
paste: 'Vložiť',
|
paste: 'Vložiť',
|
||||||
paste_from_clipboard: 'Vložiť zo schránky',
|
paste_from_clipboard: 'Vložiť zo schránky',
|
||||||
paste_request: 'Vložiť požiadavku',
|
paste_request: 'Vložiť požiadavku',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.sk = {
|
|||||||
expiry: 'Expirácia',
|
expiry: 'Expirácia',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Dôkaz platby',
|
payment_proof: 'Dôkaz platby',
|
||||||
update_available: 'Dostupná aktualizácia {version}!',
|
update_available: 'Dostupná aktualizácia %{version}!',
|
||||||
latest_update: 'Máte najnovšiu verziu {version}.',
|
latest_update: 'Máte najnovšiu verziu %{version}.',
|
||||||
notifications: 'Notifikácie',
|
notifications: 'Notifikácie',
|
||||||
no_notifications: 'Žiadne notifikácie',
|
no_notifications: 'Žiadne notifikácie',
|
||||||
notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.',
|
notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.',
|
||||||
@@ -245,8 +245,8 @@ window.localisation.sk = {
|
|||||||
look_and_feel: 'Vzhľad a dojem',
|
look_and_feel: 'Vzhľad a dojem',
|
||||||
language: 'Jazyk',
|
language: 'Jazyk',
|
||||||
color_scheme: 'Farebná schéma',
|
color_scheme: 'Farebná schéma',
|
||||||
extension_cost: 'Táto verzia vyžaduje minimálnu platbu {cost} satoshi.',
|
extension_cost: 'Táto verzia vyžaduje minimálnu platbu %{cost} satoshi.',
|
||||||
extension_paid_sats: 'Už ste zaplatili {paid_sats} sats.',
|
extension_paid_sats: 'Už ste zaplatili %{paid_sats} sats.',
|
||||||
release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.',
|
release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.',
|
||||||
pay_from_wallet: 'Zaplatiť z peňaženky',
|
pay_from_wallet: 'Zaplatiť z peňaženky',
|
||||||
show_qr: 'Zobraziť QR',
|
show_qr: 'Zobraziť QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.we = {
|
|||||||
reset_defaults: 'Ailosod i`r rhagosodiadau',
|
reset_defaults: 'Ailosod i`r rhagosodiadau',
|
||||||
reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.',
|
reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.',
|
||||||
download_backup: 'Lawrlwytho copi wrth gefn cronfa ddata',
|
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:
|
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 *',
|
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
|
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt 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',
|
view_github: 'Gweld ar GitHub',
|
||||||
voidwallet_active:
|
voidwallet_active:
|
||||||
' Mae VoidWallet yn weithredol! Taliadau wedi`u hanalluogi',
|
' Mae VoidWallet yn weithredol! Taliadau wedi`u hanalluogi',
|
||||||
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled {name} yn dal yn BETA',
|
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled %{name} yn dal yn BETA',
|
||||||
service_fee: 'Ffi gwasanaeth: {amount} % y trafodiad',
|
service_fee: 'Ffi gwasanaeth: %{amount} % y trafodiad',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Ffi gwasanaeth: {amount} % y trafodiad (uchafswm {max} sats)',
|
'Ffi gwasanaeth: %{amount} % y trafodiad (uchafswm %{max} sats)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
|
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
|
||||||
toggle_darkmode: 'Toglo Modd Tywyll',
|
toggle_darkmode: 'Toglo Modd Tywyll',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.we = {
|
|||||||
lnbits_version: 'Fersiwn LNbits',
|
lnbits_version: 'Fersiwn LNbits',
|
||||||
runs_on: 'Yn rhedeg ymlaen',
|
runs_on: 'Yn rhedeg ymlaen',
|
||||||
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
||||||
credit_label: '{denomination} i gredyd',
|
credit_label: '%{denomination} i gredyd',
|
||||||
paste: 'Gludo',
|
paste: 'Gludo',
|
||||||
paste_from_clipboard: "Gludo o'r clipfwrdd",
|
paste_from_clipboard: "Gludo o'r clipfwrdd",
|
||||||
paste_request: 'Gludo Cais',
|
paste_request: 'Gludo Cais',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.we = {
|
|||||||
expiry: 'dod i ben',
|
expiry: 'dod i ben',
|
||||||
webhook: 'bachyn we',
|
webhook: 'bachyn we',
|
||||||
payment_proof: 'prawf taliad',
|
payment_proof: 'prawf taliad',
|
||||||
update_available: 'Diweddariad {version} ar gael!',
|
update_available: 'Diweddariad %{version} ar gael!',
|
||||||
latest_update: 'Rydych chi ar y fersiwn diweddaraf {version}.',
|
latest_update: 'Rydych chi ar y fersiwn diweddaraf %{version}.',
|
||||||
notifications: 'Hysbysiadau',
|
notifications: 'Hysbysiadau',
|
||||||
no_notifications: 'Dim hysbysiadau',
|
no_notifications: 'Dim hysbysiadau',
|
||||||
notifications_disabled: "Hysbysiadau statws LNbits wedi'u analluogi.",
|
notifications_disabled: "Hysbysiadau statws LNbits wedi'u analluogi.",
|
||||||
@@ -245,8 +245,8 @@ window.localisation.we = {
|
|||||||
look_and_feel: 'Edrych a Theimlo',
|
look_and_feel: 'Edrych a Theimlo',
|
||||||
language: 'Iaith',
|
language: 'Iaith',
|
||||||
color_scheme: 'Cynllun Lliw',
|
color_scheme: 'Cynllun Lliw',
|
||||||
extension_cost: "Mae'r rhyddhad hwn yn gofyn am daliad o leiaf {cost} 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.',
|
extension_paid_sats: 'Rydych chi eisoes wedi talu %{paid_sats} sats.',
|
||||||
release_details_error: 'Methu cael manylion y rhyddhau.',
|
release_details_error: 'Methu cael manylion y rhyddhau.',
|
||||||
pay_from_wallet: "Talu o'r Waled",
|
pay_from_wallet: "Talu o'r Waled",
|
||||||
show_qr: 'Dangos QR',
|
show_qr: 'Dangos QR',
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
+38
-53
@@ -1,6 +1,6 @@
|
|||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -13,22 +13,20 @@ window.app = Vue.createApp({
|
|||||||
'confettiStars'
|
'confettiStars'
|
||||||
],
|
],
|
||||||
tab: 'user',
|
tab: 'user',
|
||||||
credentialsData: {
|
passwordData: {
|
||||||
show: false,
|
show: false,
|
||||||
oldPassword: null,
|
oldPassword: null,
|
||||||
newPassword: null,
|
newPassword: null,
|
||||||
newPasswordRepeat: null,
|
newPasswordRepeat: null
|
||||||
username: null,
|
|
||||||
pubkey: null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
activeLanguage: function (lang) {
|
activeLanguage: function (lang) {
|
||||||
return window.i18n.global.locale === lang
|
return window.i18n.locale === lang
|
||||||
},
|
},
|
||||||
changeLanguage: function (newValue) {
|
changeLanguage: function (newValue) {
|
||||||
window.i18n.global.locale = newValue
|
window.i18n.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
},
|
},
|
||||||
toggleDarkMode: function () {
|
toggleDarkMode: function () {
|
||||||
@@ -82,6 +80,20 @@ window.app = Vue.createApp({
|
|||||||
this.applyGradient()
|
this.applyGradient()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setColors: function () {
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
'lnbits.primaryColor',
|
||||||
|
LNbits.utils.getPaletteColor('primary')
|
||||||
|
)
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
'lnbits.secondaryColor',
|
||||||
|
LNbits.utils.getPaletteColor('secondary')
|
||||||
|
)
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
'lnbits.darkBgColor',
|
||||||
|
LNbits.utils.getPaletteColor('dark')
|
||||||
|
)
|
||||||
|
},
|
||||||
updateAccount: async function () {
|
updateAccount: async function () {
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
@@ -92,12 +104,11 @@ window.app = Vue.createApp({
|
|||||||
user_id: this.user.id,
|
user_id: this.user.id,
|
||||||
username: this.user.username,
|
username: this.user.username,
|
||||||
email: this.user.email,
|
email: this.user.email,
|
||||||
extra: this.user.extra
|
config: this.user.config
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
this.user = data
|
this.user = data
|
||||||
this.hasUsername = !!data.username
|
this.$q.notify({
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Account updated.'
|
message: 'Account updated.'
|
||||||
})
|
})
|
||||||
@@ -105,19 +116,11 @@ window.app = Vue.createApp({
|
|||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disableUpdatePassword: function () {
|
|
||||||
return (
|
|
||||||
!this.credentialsData.newPassword ||
|
|
||||||
!this.credentialsData.newPasswordRepeat ||
|
|
||||||
this.credentialsData.newPassword !==
|
|
||||||
this.credentialsData.newPasswordRepeat
|
|
||||||
)
|
|
||||||
},
|
|
||||||
updatePassword: async function () {
|
updatePassword: async function () {
|
||||||
if (!this.credentialsData.username) {
|
if (!this.user.username) {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Please set a username.'
|
message: 'Please set a username first.'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,16 +131,15 @@ window.app = Vue.createApp({
|
|||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
user_id: this.user.id,
|
user_id: this.user.id,
|
||||||
username: this.credentialsData.username,
|
username: this.user.username,
|
||||||
password_old: this.credentialsData.oldPassword,
|
password_old: this.passwordData.oldPassword,
|
||||||
password: this.credentialsData.newPassword,
|
password: this.passwordData.newPassword,
|
||||||
password_repeat: this.credentialsData.newPasswordRepeat
|
password_repeat: this.passwordData.newPasswordRepeat
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
this.user = data
|
this.user = data
|
||||||
this.hasUsername = !!data.username
|
this.passwordData.show = false
|
||||||
this.credentialsData.show = false
|
this.$q.notify({
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Password updated.'
|
message: 'Password updated.'
|
||||||
})
|
})
|
||||||
@@ -145,34 +147,17 @@ window.app = Vue.createApp({
|
|||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updatePubkey: async function () {
|
showChangePassword: function () {
|
||||||
try {
|
if (!this.user.username) {
|
||||||
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({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'warning',
|
||||||
message: 'Public key updated.'
|
message: 'Please set a username first.'
|
||||||
})
|
})
|
||||||
} catch (e) {
|
return
|
||||||
LNbits.utils.notifyApiError(e)
|
|
||||||
}
|
}
|
||||||
},
|
this.passwordData = {
|
||||||
showUpdateCredentials: function () {
|
|
||||||
this.credentialsData = {
|
|
||||||
show: true,
|
show: true,
|
||||||
oldPassword: null,
|
oldPassword: null,
|
||||||
username: this.user.username,
|
|
||||||
pubkey: this.user.pubkey,
|
|
||||||
newPassword: null,
|
newPassword: null,
|
||||||
newPasswordRepeat: null
|
newPasswordRepeat: null
|
||||||
}
|
}
|
||||||
@@ -183,7 +168,7 @@ window.app = Vue.createApp({
|
|||||||
const {data} = await LNbits.api.getAuthenticatedUser()
|
const {data} = await LNbits.api.getAuthenticatedUser()
|
||||||
this.user = data
|
this.user = data
|
||||||
this.hasUsername = !!data.username
|
this.hasUsername = !!data.username
|
||||||
if (!this.user.extra) this.user.extra = {}
|
if (!this.user.config) this.user.config = {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -41,7 +41,6 @@ window.app = Vue.createApp({
|
|||||||
formAddExtensionsManifest: '',
|
formAddExtensionsManifest: '',
|
||||||
formAllowedIPs: '',
|
formAllowedIPs: '',
|
||||||
formBlockedIPs: '',
|
formBlockedIPs: '',
|
||||||
nostrAcceptedUrl: '',
|
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
wallet: {},
|
wallet: {},
|
||||||
cancel: {},
|
cancel: {},
|
||||||
@@ -182,21 +181,11 @@ window.app = Vue.createApp({
|
|||||||
b => b !== blocked_ip
|
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() {
|
restartServer() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/admin/api/v1/restart/')
|
.request('GET', '/admin/api/v1/restart/')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! Restarted Server',
|
message: 'Success! Restarted Server',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -271,7 +260,7 @@ window.app = Vue.createApp({
|
|||||||
this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch
|
this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch
|
||||||
this.settings = this.formData
|
this.settings = this.formData
|
||||||
this.formData = _.clone(this.settings)
|
this.formData = _.clone(this.settings)
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: `Success! Settings changed! ${
|
message: `Success! Settings changed! ${
|
||||||
this.needsRestart ? 'Restart required!' : ''
|
this.needsRestart ? 'Restart required!' : ''
|
||||||
@@ -290,7 +279,7 @@ window.app = Vue.createApp({
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request('DELETE', '/admin/api/v1/settings')
|
.request('DELETE', '/admin/api/v1/settings')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message:
|
message:
|
||||||
'Success! Restored settings to defaults, restart required!',
|
'Success! Restored settings to defaults, restart required!',
|
||||||
|
|||||||
+35
-126
@@ -1,10 +1,15 @@
|
|||||||
|
/* globals crypto, moment, Vue, axios, Quasar, _ */
|
||||||
|
|
||||||
|
Vue.use(VueI18n)
|
||||||
|
|
||||||
window.LOCALE = 'en'
|
window.LOCALE = 'en'
|
||||||
window.i18n = new VueI18n.createI18n({
|
window.i18n = new VueI18n({
|
||||||
locale: window.LOCALE,
|
locale: window.LOCALE,
|
||||||
fallbackLocale: window.LOCALE,
|
fallbackLocale: window.LOCALE,
|
||||||
messages: window.localisation
|
messages: window.localisation
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.EventHub = new Vue()
|
||||||
window.LNbits = {
|
window.LNbits = {
|
||||||
api: {
|
api: {
|
||||||
request: function (method, url, apiKey, data) {
|
request: function (method, url, apiKey, data) {
|
||||||
@@ -17,9 +22,6 @@ window.LNbits = {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getServerHealth: function () {
|
|
||||||
return this.request('get', '/api/v1/health')
|
|
||||||
},
|
|
||||||
createInvoice: async function (
|
createInvoice: async function (
|
||||||
wallet,
|
wallet,
|
||||||
amount,
|
amount,
|
||||||
@@ -81,17 +83,6 @@ 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) {
|
login: function (username, password) {
|
||||||
return axios({
|
return axios({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -99,14 +90,6 @@ window.LNbits = {
|
|||||||
data: {username, password}
|
data: {username, password}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
loginByProvider: function (provider, headers, data) {
|
|
||||||
return axios({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/v1/auth/${provider}`,
|
|
||||||
headers: headers,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
},
|
|
||||||
loginUsr: function (usr) {
|
loginUsr: function (usr) {
|
||||||
return axios({
|
return axios({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -162,15 +145,10 @@ window.LNbits = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateBalance: function (credit, wallet_id) {
|
updateBalance: function (credit, wallet_id) {
|
||||||
return this.request('PUT', '/users/api/v1/topup', null, {
|
return LNbits.api.request('PUT', '/users/api/v1/topup', null, {
|
||||||
amount: credit,
|
amount: credit,
|
||||||
id: wallet_id
|
id: wallet_id
|
||||||
})
|
})
|
||||||
},
|
|
||||||
getCurrencies() {
|
|
||||||
return this.request('GET', '/api/v1/currencies').then(response => {
|
|
||||||
return ['sats', ...response.data]
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -209,7 +187,7 @@ window.LNbits = {
|
|||||||
},
|
},
|
||||||
map: {
|
map: {
|
||||||
extension: function (data) {
|
extension: function (data) {
|
||||||
const obj = _.object(
|
var obj = _.object(
|
||||||
[
|
[
|
||||||
'code',
|
'code',
|
||||||
'isValid',
|
'isValid',
|
||||||
@@ -226,7 +204,7 @@ window.LNbits = {
|
|||||||
return obj
|
return obj
|
||||||
},
|
},
|
||||||
user: function (data) {
|
user: function (data) {
|
||||||
const obj = {
|
var obj = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
admin: data.admin,
|
admin: data.admin,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -234,7 +212,7 @@ window.LNbits = {
|
|||||||
wallets: data.wallets,
|
wallets: data.wallets,
|
||||||
admin: data.admin
|
admin: data.admin
|
||||||
}
|
}
|
||||||
const mapWallet = this.wallet
|
var mapWallet = this.wallet
|
||||||
obj.wallets = obj.wallets
|
obj.wallets = obj.wallets
|
||||||
.map(function (obj) {
|
.map(function (obj) {
|
||||||
return mapWallet(obj)
|
return mapWallet(obj)
|
||||||
@@ -278,7 +256,7 @@ window.LNbits = {
|
|||||||
preimage: data.preimage,
|
preimage: data.preimage,
|
||||||
payment_hash: data.payment_hash,
|
payment_hash: data.payment_hash,
|
||||||
expiry: data.expiry,
|
expiry: data.expiry,
|
||||||
extra: data.extra ?? {},
|
extra: data.extra,
|
||||||
wallet_id: data.wallet_id,
|
wallet_id: data.wallet_id,
|
||||||
webhook: data.webhook,
|
webhook: data.webhook,
|
||||||
webhook_status: data.webhook_status,
|
webhook_status: data.webhook_status,
|
||||||
@@ -286,12 +264,12 @@ window.LNbits = {
|
|||||||
fiat_currency: data.fiat_currency
|
fiat_currency: data.fiat_currency
|
||||||
}
|
}
|
||||||
|
|
||||||
obj.date = Quasar.date.formatDate(
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
new Date(obj.time * 1000),
|
new Date(obj.time * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
'YYYY-MM-DD HH:mm'
|
||||||
)
|
)
|
||||||
obj.dateFrom = moment(obj.date).fromNow()
|
obj.dateFrom = moment(obj.date).fromNow()
|
||||||
obj.expirydate = Quasar.date.formatDate(
|
obj.expirydate = Quasar.utils.date.formatDate(
|
||||||
new Date(obj.expiry * 1000),
|
new Date(obj.expiry * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
'YYYY-MM-DD HH:mm'
|
||||||
)
|
)
|
||||||
@@ -316,7 +294,7 @@ window.LNbits = {
|
|||||||
},
|
},
|
||||||
utils: {
|
utils: {
|
||||||
confirmDialog: function (msg) {
|
confirmDialog: function (msg) {
|
||||||
return Quasar.Dialog.create({
|
return Quasar.plugins.Dialog.create({
|
||||||
message: msg,
|
message: msg,
|
||||||
ok: {
|
ok: {
|
||||||
flat: true,
|
flat: true,
|
||||||
@@ -337,12 +315,6 @@ window.LNbits = {
|
|||||||
.join('')
|
.join('')
|
||||||
return hashHex
|
return hashHex
|
||||||
},
|
},
|
||||||
formatDate: function (timestamp) {
|
|
||||||
return Quasar.date.formatDate(
|
|
||||||
new Date(timestamp * 1000),
|
|
||||||
'YYYY-MM-DD HH:mm'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
formatCurrency: function (value, currency) {
|
formatCurrency: function (value, currency) {
|
||||||
return new Intl.NumberFormat(window.LOCALE, {
|
return new Intl.NumberFormat(window.LOCALE, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -356,15 +328,13 @@ window.LNbits = {
|
|||||||
return this.formatSat(value / 1000)
|
return this.formatSat(value / 1000)
|
||||||
},
|
},
|
||||||
notifyApiError: function (error) {
|
notifyApiError: function (error) {
|
||||||
if (!error.response) {
|
console.error(error)
|
||||||
return console.error(error)
|
var types = {
|
||||||
}
|
|
||||||
const types = {
|
|
||||||
400: 'warning',
|
400: 'warning',
|
||||||
401: 'warning',
|
401: 'warning',
|
||||||
500: 'negative'
|
500: 'negative'
|
||||||
}
|
}
|
||||||
Quasar.Notify.create({
|
Quasar.plugins.Notify.create({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
type: types[error.response.status] || 'warning',
|
type: types[error.response.status] || 'warning',
|
||||||
message:
|
message:
|
||||||
@@ -378,9 +348,9 @@ window.LNbits = {
|
|||||||
},
|
},
|
||||||
search: function (data, q, field, separator) {
|
search: function (data, q, field, separator) {
|
||||||
try {
|
try {
|
||||||
const queries = q.toLowerCase().split(separator || ' ')
|
var queries = q.toLowerCase().split(separator || ' ')
|
||||||
return data.filter(function (obj) {
|
return data.filter(function (obj) {
|
||||||
let matches = 0
|
var matches = 0
|
||||||
_.each(queries, function (q) {
|
_.each(queries, function (q) {
|
||||||
if (obj[field].indexOf(q) !== -1) matches++
|
if (obj[field].indexOf(q) !== -1) matches++
|
||||||
})
|
})
|
||||||
@@ -409,8 +379,8 @@ window.LNbits = {
|
|||||||
return new URLSearchParams(query)
|
return new URLSearchParams(query)
|
||||||
},
|
},
|
||||||
exportCSV: function (columns, data, fileName) {
|
exportCSV: function (columns, data, fileName) {
|
||||||
const wrapCsvValue = function (val, formatFn) {
|
var wrapCsvValue = function (val, formatFn) {
|
||||||
let formatted = formatFn !== void 0 ? formatFn(val) : val
|
var formatted = formatFn !== void 0 ? formatFn(val) : val
|
||||||
|
|
||||||
formatted =
|
formatted =
|
||||||
formatted === void 0 || formatted === null ? '' : String(formatted)
|
formatted === void 0 || formatted === null ? '' : String(formatted)
|
||||||
@@ -420,7 +390,7 @@ window.LNbits = {
|
|||||||
return `"${formatted}"`
|
return `"${formatted}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = [
|
var content = [
|
||||||
columns.map(function (col) {
|
columns.map(function (col) {
|
||||||
return wrapCsvValue(col.label)
|
return wrapCsvValue(col.label)
|
||||||
})
|
})
|
||||||
@@ -441,14 +411,14 @@ window.LNbits = {
|
|||||||
)
|
)
|
||||||
.join('\r\n')
|
.join('\r\n')
|
||||||
|
|
||||||
const status = Quasar.exportFile(
|
var status = Quasar.utils.exportFile(
|
||||||
`${fileName || 'table-export'}.csv`,
|
`${fileName || 'table-export'}.csv`,
|
||||||
content,
|
content,
|
||||||
'text/csv'
|
'text/csv'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (status !== true) {
|
if (status !== true) {
|
||||||
Quasar.Notify.create({
|
Quasar.plugins.Notify.create({
|
||||||
message: 'Browser denied file download...',
|
message: 'Browser denied file download...',
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -462,16 +432,16 @@ window.LNbits = {
|
|||||||
return converter.makeHtml(text)
|
return converter.makeHtml(text)
|
||||||
},
|
},
|
||||||
hexToRgb: function (hex) {
|
hexToRgb: function (hex) {
|
||||||
return Quasar.colors.hexToRgb(hex)
|
return Quasar.utils.colors.hexToRgb(hex)
|
||||||
},
|
},
|
||||||
hexDarken: function (hex, percent) {
|
hexDarken: function (hex, percent) {
|
||||||
return Quasar.colors.lighten(hex, percent)
|
return Quasar.utils.colors.lighten(hex, percent)
|
||||||
},
|
},
|
||||||
hexAlpha: function (hex, alpha) {
|
hexAlpha: function (hex, alpha) {
|
||||||
return Quasar.colors.changeAlpha(hex, alpha)
|
return Quasar.utils.colors.changeAlpha(hex, alpha)
|
||||||
},
|
},
|
||||||
getPaletteColor: function (color) {
|
getPaletteColor: function (color) {
|
||||||
return Quasar.colors.getPaletteColor(color)
|
return Quasar.utils.colors.getPaletteColor(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,7 +475,6 @@ window.windowMixin = {
|
|||||||
},
|
},
|
||||||
applyGradient: function () {
|
applyGradient: function () {
|
||||||
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
|
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
|
||||||
this.setColors()
|
|
||||||
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
|
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
|
||||||
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
|
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
|
||||||
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
|
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
|
||||||
@@ -523,23 +492,10 @@ window.windowMixin = {
|
|||||||
document.head.appendChild(style)
|
document.head.appendChild(style)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setColors: function () {
|
|
||||||
this.$q.localStorage.set(
|
|
||||||
'lnbits.primaryColor',
|
|
||||||
LNbits.utils.getPaletteColor('primary')
|
|
||||||
)
|
|
||||||
this.$q.localStorage.set(
|
|
||||||
'lnbits.secondaryColor',
|
|
||||||
LNbits.utils.getPaletteColor('secondary')
|
|
||||||
)
|
|
||||||
this.$q.localStorage.set(
|
|
||||||
'lnbits.darkBgColor',
|
|
||||||
LNbits.utils.getPaletteColor('dark')
|
|
||||||
)
|
|
||||||
},
|
|
||||||
copyText: function (text, message, position) {
|
copyText: function (text, message, position) {
|
||||||
Quasar.copyToClipboard(text).then(function () {
|
var notify = this.$q.notify
|
||||||
Quasar.Notify.create({
|
Quasar.utils.copyToClipboard(text).then(function () {
|
||||||
|
notify({
|
||||||
message: message || 'Copied to clipboard!',
|
message: message || 'Copied to clipboard!',
|
||||||
position: position || 'bottom'
|
position: position || 'bottom'
|
||||||
})
|
})
|
||||||
@@ -585,52 +541,6 @@ window.windowMixin = {
|
|||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
themeParams() {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const fields = ['theme', 'dark', 'gradient']
|
|
||||||
const toBoolean = value =>
|
|
||||||
value.trim().toLowerCase() === 'true' || value === '1'
|
|
||||||
|
|
||||||
// Check if any of the relevant parameters ('theme', 'dark', 'gradient') are present in the URL.
|
|
||||||
if (fields.some(param => params.has(param))) {
|
|
||||||
const theme = params.get('theme')
|
|
||||||
const darkMode = params.get('dark')
|
|
||||||
const gradient = params.get('gradient')
|
|
||||||
|
|
||||||
if (
|
|
||||||
theme &&
|
|
||||||
this.g.allowedThemes.includes(theme.trim().toLowerCase())
|
|
||||||
) {
|
|
||||||
const normalizedTheme = theme.trim().toLowerCase()
|
|
||||||
document.body.setAttribute('data-theme', normalizedTheme)
|
|
||||||
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (darkMode) {
|
|
||||||
const isDark = toBoolean(darkMode)
|
|
||||||
this.$q.localStorage.set('lnbits.darkMode', isDark)
|
|
||||||
if (!isDark) {
|
|
||||||
this.$q.localStorage.set('lnbits.gradientBg', false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gradient) {
|
|
||||||
const isGradient = toBoolean(gradient)
|
|
||||||
this.$q.localStorage.set('lnbits.gradientBg', isGradient)
|
|
||||||
if (isGradient) {
|
|
||||||
this.$q.localStorage.set('lnbits.darkMode', true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove processed parameters
|
|
||||||
fields.forEach(param => params.delete(param))
|
|
||||||
|
|
||||||
window.history.replaceState(null, null, url.pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setColors()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: async function () {
|
created: async function () {
|
||||||
@@ -645,12 +555,14 @@ window.windowMixin = {
|
|||||||
this.reactionChoice =
|
this.reactionChoice =
|
||||||
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
|
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
|
||||||
|
|
||||||
|
this.applyGradient()
|
||||||
|
|
||||||
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
|
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
|
||||||
|
|
||||||
let locale = this.$q.localStorage.getItem('lnbits.lang')
|
let locale = this.$q.localStorage.getItem('lnbits.lang')
|
||||||
if (locale) {
|
if (locale) {
|
||||||
window.LOCALE = locale
|
window.LOCALE = locale
|
||||||
window.i18n.global.locale = locale
|
window.i18n.locale = locale
|
||||||
}
|
}
|
||||||
|
|
||||||
this.g.langs = window.langs ?? []
|
this.g.langs = window.langs ?? []
|
||||||
@@ -683,8 +595,6 @@ window.windowMixin = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.applyGradient()
|
|
||||||
|
|
||||||
if (window.user) {
|
if (window.user) {
|
||||||
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
|
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
|
||||||
}
|
}
|
||||||
@@ -692,7 +602,7 @@ window.windowMixin = {
|
|||||||
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
|
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
|
||||||
}
|
}
|
||||||
if (window.extensions) {
|
if (window.extensions) {
|
||||||
const user = this.g.user
|
var user = this.g.user
|
||||||
const extensions = Object.freeze(
|
const extensions = Object.freeze(
|
||||||
window.extensions
|
window.extensions
|
||||||
.map(function (data) {
|
.map(function (data) {
|
||||||
@@ -723,7 +633,6 @@ window.windowMixin = {
|
|||||||
this.g.extensions = extensions
|
this.g.extensions = extensions
|
||||||
}
|
}
|
||||||
await this.checkUsrInUrl()
|
await this.checkUsrInUrl()
|
||||||
this.themeParams()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+385
-80
@@ -1,19 +1,13 @@
|
|||||||
window.app.component(QrcodeVue)
|
/* global _, Vue, moment, LNbits, EventHub, decryptLnurlPayAES */
|
||||||
|
|
||||||
window.app.component('lnbits-extension-rating', {
|
Vue.component('lnbits-fsat', {
|
||||||
template: '#lnbits-extension-rating',
|
|
||||||
name: 'lnbits-extension-rating',
|
|
||||||
props: ['rating']
|
|
||||||
})
|
|
||||||
|
|
||||||
window.app.component('lnbits-fsat', {
|
|
||||||
template: '<span>{{ fsat }}</span>',
|
|
||||||
props: {
|
props: {
|
||||||
amount: {
|
amount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
template: '<span>{{ fsat }}</span>',
|
||||||
computed: {
|
computed: {
|
||||||
fsat: function () {
|
fsat: function () {
|
||||||
return LNbits.utils.formatSat(this.amount)
|
return LNbits.utils.formatSat(this.amount)
|
||||||
@@ -21,22 +15,66 @@ window.app.component('lnbits-fsat', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-wallet-list', {
|
Vue.component('lnbits-wallet-list', {
|
||||||
template: '#lnbits-wallet-list',
|
|
||||||
props: ['balance'],
|
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
activeWallet: null,
|
activeWallet: null,
|
||||||
balance: 0,
|
activeBalance: [],
|
||||||
showForm: false,
|
showForm: false,
|
||||||
walletName: '',
|
walletName: '',
|
||||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
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: {
|
computed: {
|
||||||
wallets: function () {
|
wallets: function () {
|
||||||
var bal = this.balance
|
var bal = this.activeBalance
|
||||||
return this.user.wallets.map(function (obj) {
|
return this.user.wallets.map(function (obj) {
|
||||||
obj.live_fsat =
|
obj.live_fsat =
|
||||||
bal.length && bal[0] === obj.id
|
bal.length && bal[0] === obj.id
|
||||||
@@ -49,6 +87,9 @@ window.app.component('lnbits-wallet-list', {
|
|||||||
methods: {
|
methods: {
|
||||||
createWallet: function () {
|
createWallet: function () {
|
||||||
LNbits.api.createWallet(this.user.wallets[0], this.walletName)
|
LNbits.api.createWallet(this.user.wallets[0], this.walletName)
|
||||||
|
},
|
||||||
|
updateWalletBalance: function (payload) {
|
||||||
|
this.activeBalance = payload
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function () {
|
created: function () {
|
||||||
@@ -58,18 +99,42 @@ window.app.component('lnbits-wallet-list', {
|
|||||||
if (window.wallet) {
|
if (window.wallet) {
|
||||||
this.activeWallet = LNbits.map.wallet(window.wallet)
|
this.activeWallet = LNbits.map.wallet(window.wallet)
|
||||||
}
|
}
|
||||||
document.addEventListener('updateWalletBalance', this.updateWalletBalance)
|
EventHub.$on('update-wallet-balance', this.updateWalletBalance)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-extension-list', {
|
Vue.component('lnbits-extension-list', {
|
||||||
template: '#lnbits-extension-list',
|
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
user: null
|
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: {
|
computed: {
|
||||||
userExtensions: function () {
|
userExtensions: function () {
|
||||||
if (!this.user) return []
|
if (!this.user) return []
|
||||||
@@ -104,36 +169,140 @@ window.app.component('lnbits-extension-list', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-manage', {
|
Vue.component('lnbits-manage', {
|
||||||
template: '#lnbits-manage',
|
|
||||||
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
|
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
|
||||||
methods: {
|
methods: {
|
||||||
isActive: function (path) {
|
isActive: function (path) {
|
||||||
return window.location.pathname === path
|
return window.location.pathname === path
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
user: null
|
user: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
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 () {
|
||||||
if (window.user) {
|
if (window.user) {
|
||||||
this.user = LNbits.map.user(window.user)
|
this.user = LNbits.map.user(window.user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-payment-details', {
|
Vue.component('lnbits-payment-details', {
|
||||||
template: '#lnbits-payment-details',
|
|
||||||
props: ['payment'],
|
props: ['payment'],
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
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>: {{ 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>: {{ payment.memo }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-wrap" v-if="payment.webhook">
|
||||||
|
<b style="white-space: nowrap;" v-text="$t('webhook')"></b>: {{ payment.webhook }}: <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>: {{ 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: {
|
computed: {
|
||||||
hasPreimage() {
|
hasPreimage() {
|
||||||
return (
|
return (
|
||||||
@@ -176,16 +345,27 @@ window.app.component('lnbits-payment-details', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-lnurlpay-success-action', {
|
Vue.component('lnbits-lnurlpay-success-action', {
|
||||||
template: '#lnbits-lnurlpay-success-action',
|
|
||||||
props: ['payment', 'success_action'],
|
props: ['payment', 'success_action'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
decryptedValue: this.success_action.ciphertext
|
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 () {
|
mounted: function () {
|
||||||
if (this.success_action.tag !== 'aes') return null
|
if (this.success_action.tag !== 'aes') return null
|
||||||
|
|
||||||
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
|
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
|
||||||
value => {
|
value => {
|
||||||
this.decryptedValue = value
|
this.decryptedValue = value
|
||||||
@@ -194,23 +374,26 @@ window.app.component('lnbits-lnurlpay-success-action', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-qrcode', {
|
Vue.component('lnbits-qrcode', {
|
||||||
template: '#lnbits-qrcode',
|
mixins: [windowMixin],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
components: {
|
|
||||||
QrcodeVue
|
|
||||||
},
|
|
||||||
props: ['value'],
|
props: ['value'],
|
||||||
|
components: {[VueQrcode.name]: VueQrcode},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
logo: LNBITS_QR_LOGO
|
logo: LNBITS_QR_LOGO
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
template: `
|
||||||
|
<div class="qrcode__wrapper">
|
||||||
|
<qrcode :value="value"
|
||||||
|
:options="{errorCorrectionLevel: 'Q', width: 800}" class="rounded-borders"></qrcode>
|
||||||
|
<img class="qrcode__image" :src="logo" alt="..." />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-notifications-btn', {
|
Vue.component('lnbits-notifications-btn', {
|
||||||
template: '#lnbits-notifications-btn',
|
mixins: [windowMixin],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['pubkey'],
|
props: ['pubkey'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -220,6 +403,26 @@ window.app.component('lnbits-notifications-btn', {
|
|||||||
isPermissionDenied: false
|
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: {
|
methods: {
|
||||||
// converts base64 to Array buffer
|
// converts base64 to Array buffer
|
||||||
urlB64ToUint8Array(base64String) {
|
urlB64ToUint8Array(base64String) {
|
||||||
@@ -402,16 +605,120 @@ window.app.component('lnbits-notifications-btn', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-dynamic-fields', {
|
Vue.component('lnbits-dynamic-fields', {
|
||||||
template: '#lnbits-dynamic-fields',
|
mixins: [windowMixin],
|
||||||
mixins: [window.windowMixin],
|
props: ['options', 'value'],
|
||||||
props: ['options', 'modelValue'],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formData: null,
|
formData: null,
|
||||||
rules: [val => !!val || 'Field is required']
|
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: {
|
methods: {
|
||||||
applyRules(required) {
|
applyRules(required) {
|
||||||
return required ? this.rules : []
|
return required ? this.rules : []
|
||||||
@@ -427,55 +734,23 @@ window.app.component('lnbits-dynamic-fields', {
|
|||||||
}, {})
|
}, {})
|
||||||
},
|
},
|
||||||
handleValueChanged() {
|
handleValueChanged() {
|
||||||
this.$emit('update:model-value', this.formData)
|
this.$emit('input', this.formData)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created: function () {
|
||||||
this.formData = this.buildData(this.options, this.modelValue)
|
this.formData = this.buildData(this.options, this.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-dynamic-chips', {
|
Vue.component('lnbits-update-balance', {
|
||||||
template: '#lnbits-dynamic-chips',
|
mixins: [windowMixin],
|
||||||
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'],
|
props: ['wallet_id', 'callback'],
|
||||||
computed: {
|
computed: {
|
||||||
denomination() {
|
denomination() {
|
||||||
return LNBITS_DENOMINATION
|
return LNBITS_DENOMINATION
|
||||||
},
|
},
|
||||||
admin() {
|
admin() {
|
||||||
return user.super_user
|
return this.g.user.admin
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -499,7 +774,7 @@ window.app.component('lnbits-update-balance', {
|
|||||||
})
|
})
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
credit = parseInt(credit)
|
credit = parseInt(credit)
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: this.$t('wallet_topup_ok', {
|
message: this.$t('wallet_topup_ok', {
|
||||||
amount: credit
|
amount: credit
|
||||||
@@ -512,5 +787,35 @@ window.app.component('lnbits-update-balance', {
|
|||||||
LNbits.utils.notifyApiError(error)
|
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>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Vue.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,11 +1,10 @@
|
|||||||
window.app.component('lnbits-extension-settings-form', {
|
Vue.component('lnbits-extension-settings-form', {
|
||||||
name: 'lnbits-extension-settings-form',
|
name: 'lnbits-extension-settings-form',
|
||||||
template: '#lnbits-extension-settings-form',
|
|
||||||
props: ['options', 'adminkey', 'endpoint'],
|
props: ['options', 'adminkey', 'endpoint'],
|
||||||
methods: {
|
methods: {
|
||||||
async updateSettings() {
|
updateSettings: async function () {
|
||||||
if (!this.settings) {
|
if (!this.settings) {
|
||||||
return Quasar.Notify.create({
|
return Quasar.plugins.Notify.create({
|
||||||
message: 'No settings to update',
|
message: 'No settings to update',
|
||||||
type: 'negative'
|
type: 'negative'
|
||||||
})
|
})
|
||||||
@@ -50,6 +49,16 @@ window.app.component('lnbits-extension-settings-form', {
|
|||||||
created: async function () {
|
created: async function () {
|
||||||
await this.getSettings()
|
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 () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
settings: undefined
|
settings: undefined
|
||||||
@@ -57,10 +66,22 @@ window.app.component('lnbits-extension-settings-form', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-extension-settings-btn-dialog', {
|
Vue.component('lnbits-extension-settings-btn-dialog', {
|
||||||
template: '#lnbits-extension-settings-btn-dialog',
|
|
||||||
name: 'lnbits-extension-settings-btn-dialog',
|
name: 'lnbits-extension-settings-btn-dialog',
|
||||||
props: ['options', 'adminkey', 'endpoint'],
|
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 () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
show: false
|
show: false
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-funding-sources', {
|
Vue.component('lnbits-funding-sources', {
|
||||||
template: '#lnbits-funding-sources',
|
mixins: [windowMixin],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['form-data', 'allowed-funding-sources'],
|
props: ['form-data', 'allowed-funding-sources'],
|
||||||
methods: {
|
methods: {
|
||||||
getFundingSourceLabel(item) {
|
getFundingSourceLabel(item) {
|
||||||
@@ -197,5 +196,45 @@ 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>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ function generateChart(canvas, rawData) {
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
label: 'in',
|
label: 'in',
|
||||||
barPercentage: 0.75,
|
barPercentage: 0.75,
|
||||||
backgroundColor: 'rgba(76, 175, 80, 0.5)' // green
|
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: data.spending,
|
data: data.spending,
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
label: 'out',
|
label: 'out',
|
||||||
barPercentage: 0.75,
|
barPercentage: 0.75,
|
||||||
backgroundColor: 'rgba(233, 30, 99, 0.5)' // pink
|
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -80,11 +80,10 @@ function generateChart(canvas, rawData) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.component('payment-chart', {
|
Vue.component('payment-chart', {
|
||||||
template: '#payment-chart',
|
|
||||||
name: 'payment-chart',
|
name: 'payment-chart',
|
||||||
props: ['wallet'],
|
props: ['wallet'],
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
paymentsChart: {
|
paymentsChart: {
|
||||||
@@ -129,5 +128,30 @@ window.app.component('payment-chart', {
|
|||||||
this.paymentsChart.show = false
|
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>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
window.app.component('payment-list', {
|
Vue.component('payment-list', {
|
||||||
name: 'payment-list',
|
name: 'payment-list',
|
||||||
template: '#payment-list',
|
|
||||||
props: ['update', 'wallet', 'mobileSimple', 'lazy'],
|
props: ['update', 'wallet', 'mobileSimple', 'lazy'],
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
denomination: LNBITS_DENOMINATION,
|
denomination: LNBITS_DENOMINATION,
|
||||||
@@ -224,5 +223,280 @@ window.app.component('payment-list', {
|
|||||||
},
|
},
|
||||||
created: function () {
|
created: function () {
|
||||||
if (this.lazy === undefined) this.fetchPayments()
|
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
|
||||||
|
:data="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>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
disclaimerDialog: {
|
disclaimerDialog: {
|
||||||
@@ -13,7 +13,6 @@ window.app = Vue.createApp({
|
|||||||
authMethod: 'username-password',
|
authMethod: 'username-password',
|
||||||
usr: '',
|
usr: '',
|
||||||
username: '',
|
username: '',
|
||||||
reset_key: '',
|
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
passwordRepeat: '',
|
passwordRepeat: '',
|
||||||
@@ -43,79 +42,6 @@ window.app = Vue.createApp({
|
|||||||
this.authAction = 'register'
|
this.authAction = 'register'
|
||||||
this.authMethod = authMethod
|
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 () {
|
register: async function () {
|
||||||
try {
|
try {
|
||||||
await LNbits.api.register(
|
await LNbits.api.register(
|
||||||
@@ -129,18 +55,6 @@ window.app = Vue.createApp({
|
|||||||
LNbits.utils.notifyApiError(e)
|
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 () {
|
login: async function () {
|
||||||
try {
|
try {
|
||||||
await LNbits.api.login(this.username, this.password)
|
await LNbits.api.login(this.username, this.password)
|
||||||
@@ -155,7 +69,6 @@ window.app = Vue.createApp({
|
|||||||
this.usr = ''
|
this.usr = ''
|
||||||
window.location.href = '/wallet'
|
window.location.href = '/wallet'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e)
|
|
||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -165,7 +78,7 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
processing: function () {
|
processing: function () {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: 'Processing...',
|
message: 'Processing...',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -180,15 +93,10 @@ window.app = Vue.createApp({
|
|||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.description = SITE_DESCRIPTION
|
this.description = SITE_DESCRIPTION
|
||||||
|
|
||||||
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
|
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
|
||||||
if (this.isUserAuthorized) {
|
if (this.isUserAuthorized) {
|
||||||
window.location.href = '/wallet'
|
window.location.href = '/wallet'
|
||||||
}
|
}
|
||||||
this.reset_key = new URLSearchParams(window.location.search).get(
|
|
||||||
'reset_key'
|
|
||||||
)
|
|
||||||
if (this.reset_key) {
|
|
||||||
this.authAction = 'reset'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
window.app.use(VueQrcodeReader)
|
|
||||||
window.app.use(Quasar)
|
|
||||||
window.app.use(window.i18n)
|
|
||||||
window.app.mount('#vue')
|
|
||||||
+12
-12
@@ -4,7 +4,7 @@ function shortenNodeId(nodeId) {
|
|||||||
: '...'
|
: '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.component('lnbits-node-ranks', {
|
Vue.component('lnbits-node-ranks', {
|
||||||
props: ['ranks'],
|
props: ['ranks'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +35,7 @@ window.app.component('lnbits-node-ranks', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-channel-stats', {
|
Vue.component('lnbits-channel-stats', {
|
||||||
props: ['stats'],
|
props: ['stats'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
@@ -71,7 +71,7 @@ window.app.component('lnbits-channel-stats', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-stat', {
|
Vue.component('lnbits-stat', {
|
||||||
props: ['title', 'amount', 'msat', 'btc'],
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
computed: {
|
computed: {
|
||||||
value: function () {
|
value: function () {
|
||||||
@@ -99,20 +99,20 @@ window.app.component('lnbits-stat', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-node-qrcode', {
|
Vue.component('lnbits-node-qrcode', {
|
||||||
props: ['info'],
|
props: ['info'],
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
template: `
|
template: `
|
||||||
<q-card class="my-card">
|
<q-card class="my-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
<div style="text-align: center">
|
<div style="text-align: center">
|
||||||
<vue-qrcode
|
<qrcode
|
||||||
:value="info.addresses[0]"
|
:value="info.addresses[0]"
|
||||||
:options="{width: 250}"
|
:options="{width: 250}"
|
||||||
v-if='info.addresses[0]'
|
v-if='info.addresses[0]'
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></vue-qrcode>
|
></qrcode>
|
||||||
<div v-else class='text-subtitle1'>
|
<div v-else class='text-subtitle1'>
|
||||||
No addresses available
|
No addresses available
|
||||||
</div>
|
</div>
|
||||||
@@ -132,14 +132,14 @@ window.app.component('lnbits-node-qrcode', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-node-info', {
|
Vue.component('lnbits-node-info', {
|
||||||
props: ['info'],
|
props: ['info'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showDialog: false
|
showDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
methods: {
|
methods: {
|
||||||
shortenNodeId
|
shortenNodeId
|
||||||
},
|
},
|
||||||
@@ -177,7 +177,7 @@ window.app.component('lnbits-node-info', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-stat', {
|
Vue.component('lnbits-stat', {
|
||||||
props: ['title', 'amount', 'msat', 'btc'],
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
computed: {
|
computed: {
|
||||||
value: function () {
|
value: function () {
|
||||||
@@ -205,7 +205,7 @@ window.app.component('lnbits-stat', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-channel-balance', {
|
Vue.component('lnbits-channel-balance', {
|
||||||
props: ['balance', 'color'],
|
props: ['balance', 'color'],
|
||||||
methods: {
|
methods: {
|
||||||
formatMsat: function (msat) {
|
formatMsat: function (msat) {
|
||||||
@@ -246,7 +246,7 @@ window.app.component('lnbits-channel-balance', {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-date', {
|
Vue.component('lnbits-date', {
|
||||||
props: ['ts'],
|
props: ['ts'],
|
||||||
computed: {
|
computed: {
|
||||||
date: function () {
|
date: function () {
|
||||||
|
|||||||
+13
-64
@@ -1,6 +1,6 @@
|
|||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -164,31 +164,6 @@ window.app = Vue.createApp({
|
|||||||
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
|
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
|
||||||
type: 'bubble',
|
type: 'bubble',
|
||||||
options: {
|
options: {
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'linear',
|
|
||||||
beginAtZero: true,
|
|
||||||
title: {
|
|
||||||
text: 'Transaction count'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
beginAtZero: true,
|
|
||||||
title: {
|
|
||||||
text: 'User balance in million sats'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: function (tooltipItem, data) {
|
|
||||||
const dataset = data.datasets[tooltipItem.datasetIndex]
|
|
||||||
const dataPoint = dataset.data[tooltipItem.index]
|
|
||||||
return dataPoint.customLabel || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layout: {
|
layout: {
|
||||||
padding: 10
|
padding: 10
|
||||||
}
|
}
|
||||||
@@ -196,7 +171,7 @@ window.app = Vue.createApp({
|
|||||||
data: {
|
data: {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Wallet balance vs transaction count',
|
label: 'Balance - TX Count in million sats',
|
||||||
backgroundColor: 'rgb(255, 99, 132)',
|
backgroundColor: 'rgb(255, 99, 132)',
|
||||||
data: []
|
data: []
|
||||||
}
|
}
|
||||||
@@ -205,34 +180,18 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatDate: function (value) {
|
|
||||||
return LNbits.utils.formatDate(value)
|
|
||||||
},
|
|
||||||
formatSat: function (value) {
|
formatSat: function (value) {
|
||||||
return LNbits.utils.formatSat(Math.floor(value / 1000))
|
return LNbits.utils.formatSat(Math.floor(value / 1000))
|
||||||
},
|
},
|
||||||
resetPassword(user_id) {
|
usersTableRowKey: function (row) {
|
||||||
return LNbits.api
|
return row.id
|
||||||
.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() {
|
createUser() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
|
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchUsers()
|
this.fetchUsers()
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! User created!',
|
message: 'Success! User created!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -252,7 +211,7 @@ window.app = Vue.createApp({
|
|||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchUsers()
|
this.fetchUsers()
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! User created!',
|
message: 'Success! User created!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -270,7 +229,7 @@ window.app = Vue.createApp({
|
|||||||
.request('DELETE', `/users/api/v1/user/${user_id}`)
|
.request('DELETE', `/users/api/v1/user/${user_id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchUsers()
|
this.fetchUsers()
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! User deleted!',
|
message: 'Success! User deleted!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -289,7 +248,7 @@ window.app = Vue.createApp({
|
|||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchWallets(user_id)
|
this.fetchWallets(user_id)
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! Undeleted user wallet!',
|
message: 'Success! Undeleted user wallet!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -308,7 +267,7 @@ window.app = Vue.createApp({
|
|||||||
.request('DELETE', `/users/api/v1/user/${user_id}/wallet/${wallet}`)
|
.request('DELETE', `/users/api/v1/user/${user_id}/wallet/${wallet}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchWallets(user_id)
|
this.fetchWallets(user_id)
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! User wallet deleted!',
|
message: 'Success! User wallet deleted!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -332,20 +291,10 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const data = filtered.map(user => {
|
const data = filtered.map(user => {
|
||||||
const labelUsername = `${user.username ? 'User: ' + user.username + '. ' : ''}`
|
|
||||||
const userBalanceSats = Math.floor(
|
|
||||||
user.balance_msat / 1000
|
|
||||||
).toLocaleString()
|
|
||||||
return {
|
return {
|
||||||
x: user.transaction_count,
|
x: user.transaction_count,
|
||||||
y: user.balance_msat / 1000000000,
|
y: user.balance_msat / 1000000000,
|
||||||
r: 4,
|
r: 3
|
||||||
customLabel:
|
|
||||||
labelUsername +
|
|
||||||
'Balance: ' +
|
|
||||||
userBalanceSats +
|
|
||||||
' sats. Tx count: ' +
|
|
||||||
user.transaction_count
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.chart1.data.datasets[0].data = data
|
this.chart1.data.datasets[0].data = data
|
||||||
@@ -388,7 +337,7 @@ window.app = Vue.createApp({
|
|||||||
.request('GET', `/users/api/v1/user/${user_id}/admin`)
|
.request('GET', `/users/api/v1/user/${user_id}/admin`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.fetchUsers()
|
this.fetchUsers()
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! Toggled admin!',
|
message: 'Success! Toggled admin!',
|
||||||
icon: null
|
icon: null
|
||||||
@@ -420,7 +369,7 @@ window.app = Vue.createApp({
|
|||||||
this.wallet
|
this.wallet
|
||||||
)
|
)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`,
|
message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`,
|
||||||
icon: null
|
icon: null
|
||||||
|
|||||||
+36
-37
@@ -1,13 +1,16 @@
|
|||||||
window.app = Vue.createApp({
|
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, decryptLnurlPayAES */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
updatePayments: false,
|
updatePayments: false,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
wallet: LNbits.map.wallet(window.wallet),
|
|
||||||
user: LNbits.map.user(window.user),
|
user: LNbits.map.user(window.user),
|
||||||
exportUrl: `${window.location.origin}/wallet?usr=${window.user.id}&wal=${window.wallet.id}`,
|
|
||||||
receive: {
|
receive: {
|
||||||
show: false,
|
show: false,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -144,11 +147,9 @@ window.app = Vue.createApp({
|
|||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.receive.status = 'success'
|
this.receive.status = 'success'
|
||||||
this.receive.paymentReq = response.data.bolt11
|
this.receive.paymentReq = response.data.payment_request
|
||||||
this.receive.paymentHash = response.data.payment_hash
|
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 !== null) {
|
||||||
if (response.data.lnurl_response === false) {
|
if (response.data.lnurl_response === false) {
|
||||||
response.data.lnurl_response = `Unable to connect`
|
response.data.lnurl_response = `Unable to connect`
|
||||||
@@ -156,7 +157,7 @@ window.app = Vue.createApp({
|
|||||||
|
|
||||||
if (typeof response.data.lnurl_response === 'string') {
|
if (typeof response.data.lnurl_response === 'string') {
|
||||||
// failure
|
// failure
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
|
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
|
||||||
@@ -165,7 +166,7 @@ window.app = Vue.createApp({
|
|||||||
return
|
return
|
||||||
} else if (response.data.lnurl_response === true) {
|
} else if (response.data.lnurl_response === true) {
|
||||||
// success
|
// success
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
|
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
|
||||||
spinner: true
|
spinner: true
|
||||||
@@ -204,7 +205,7 @@ window.app = Vue.createApp({
|
|||||||
? mapping[valid_error]
|
? mapping[valid_error]
|
||||||
: `ERROR: Camera error (${error.name})`
|
: `ERROR: Camera error (${error.name})`
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: camera_error,
|
message: camera_error,
|
||||||
type: 'negative'
|
type: 'negative'
|
||||||
})
|
})
|
||||||
@@ -224,7 +225,7 @@ window.app = Vue.createApp({
|
|||||||
let data = response.data
|
let data = response.data
|
||||||
|
|
||||||
if (data.status === 'ERROR') {
|
if (data.status === 'ERROR') {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `${data.domain} lnurl call failed.`,
|
message: `${data.domain} lnurl call failed.`,
|
||||||
@@ -259,7 +260,7 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
decodeQR: function (res) {
|
decodeQR: function (res) {
|
||||||
this.parse.data.request = res[0].rawValue
|
this.parse.data.request = res
|
||||||
this.decodeRequest()
|
this.decodeRequest()
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
@@ -294,7 +295,7 @@ window.app = Vue.createApp({
|
|||||||
try {
|
try {
|
||||||
invoice = decode(this.parse.data.request)
|
invoice = decode(this.parse.data.request)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: error + '.',
|
message: error + '.',
|
||||||
@@ -320,7 +321,7 @@ window.app = Vue.createApp({
|
|||||||
var expireDate = new Date(
|
var expireDate = new Date(
|
||||||
(invoice.data.time_stamp + tag.value) * 1000
|
(invoice.data.time_stamp + tag.value) * 1000
|
||||||
)
|
)
|
||||||
cleanInvoice.expireDate = Quasar.date.formatDate(
|
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
|
||||||
expireDate,
|
expireDate,
|
||||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||||
)
|
)
|
||||||
@@ -332,7 +333,7 @@ window.app = Vue.createApp({
|
|||||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||||
},
|
},
|
||||||
payInvoice: function () {
|
payInvoice: function () {
|
||||||
let dismissPaymentMsg = Quasar.Notify.create({
|
let dismissPaymentMsg = this.$q.notify({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: this.$t('processing_payment')
|
message: this.$t('processing_payment')
|
||||||
})
|
})
|
||||||
@@ -365,7 +366,7 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
payLnurl: function () {
|
payLnurl: function () {
|
||||||
let dismissPaymentMsg = Quasar.Notify.create({
|
let dismissPaymentMsg = this.$q.notify({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: 'Processing payment...'
|
message: 'Processing payment...'
|
||||||
})
|
})
|
||||||
@@ -395,13 +396,12 @@ window.app = Vue.createApp({
|
|||||||
dismissPaymentMsg()
|
dismissPaymentMsg()
|
||||||
clearInterval(this.parse.paymentChecker)
|
clearInterval(this.parse.paymentChecker)
|
||||||
// show lnurlpay success action
|
// show lnurlpay success action
|
||||||
const extra = response.data.extra
|
if (response.data.success_action) {
|
||||||
if (extra.success_action) {
|
switch (response.data.success_action.tag) {
|
||||||
switch (extra.success_action.tag) {
|
|
||||||
case 'url':
|
case 'url':
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
|
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
|
||||||
caption: extra.success_action.description,
|
caption: response.data.success_action.description,
|
||||||
html: true,
|
html: true,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
@@ -409,8 +409,8 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'message':
|
case 'message':
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: extra.success_action.message,
|
message: response.data.success_action.message,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
closeBtn: true
|
closeBtn: true
|
||||||
@@ -421,14 +421,14 @@ window.app = Vue.createApp({
|
|||||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||||
.then(({data: payment}) =>
|
.then(({data: payment}) =>
|
||||||
decryptLnurlPayAES(
|
decryptLnurlPayAES(
|
||||||
extra.success_action,
|
response.data.success_action,
|
||||||
payment.preimage
|
payment.preimage
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.then(value => {
|
.then(value => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: value,
|
message: value,
|
||||||
caption: extra.success_action.description,
|
caption: response.data.success_action.description,
|
||||||
html: true,
|
html: true,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
@@ -448,7 +448,7 @@ window.app = Vue.createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
authLnurl: function () {
|
authLnurl: function () {
|
||||||
let dismissAuthMsg = Quasar.Notify.create({
|
let dismissAuthMsg = this.$q.notify({
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
message: 'Performing authentication...'
|
message: 'Performing authentication...'
|
||||||
})
|
})
|
||||||
@@ -457,7 +457,7 @@ window.app = Vue.createApp({
|
|||||||
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
dismissAuthMsg()
|
dismissAuthMsg()
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: `Authentication successful.`,
|
message: `Authentication successful.`,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
@@ -467,7 +467,7 @@ window.app = Vue.createApp({
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
dismissAuthMsg()
|
dismissAuthMsg()
|
||||||
if (err.response.data.reason) {
|
if (err.response.data.reason) {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
|
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
|
||||||
caption: err.response.data.reason,
|
caption: err.response.data.reason,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -482,7 +482,7 @@ window.app = Vue.createApp({
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
message: `Wallet updated.`,
|
message: `Wallet updated.`,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
@@ -500,7 +500,7 @@ window.app = Vue.createApp({
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.deleteWallet(this.g.wallet)
|
.deleteWallet(this.g.wallet)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
Quasar.Notify.create({
|
this.$q.notify({
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
message: `Wallet deleted!`,
|
message: `Wallet deleted!`,
|
||||||
spinner: true
|
spinner: true
|
||||||
@@ -514,11 +514,10 @@ window.app = Vue.createApp({
|
|||||||
fetchBalance: function () {
|
fetchBalance: function () {
|
||||||
LNbits.api.getWallet(this.g.wallet).then(response => {
|
LNbits.api.getWallet(this.g.wallet).then(response => {
|
||||||
this.balance = Math.floor(response.data.balance / 1000)
|
this.balance = Math.floor(response.data.balance / 1000)
|
||||||
document.dispatchEvent(
|
EventHub.$emit('update-wallet-balance', [
|
||||||
new CustomEvent('updateWalletBalance', {
|
this.g.wallet.id,
|
||||||
detail: [this.g.wallet.id, this.balance]
|
this.balance
|
||||||
})
|
])
|
||||||
)
|
|
||||||
})
|
})
|
||||||
if (this.g.wallet.currency) {
|
if (this.g.wallet.currency) {
|
||||||
this.updateFiatBalance()
|
this.updateFiatBalance()
|
||||||
|
|||||||
@@ -207,24 +207,23 @@ video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// qrcode
|
// qrcode
|
||||||
.qrcode__wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.qrcode__wrapper canvas {
|
.qrcode__wrapper canvas {
|
||||||
|
position: relative;
|
||||||
width: 100% !important; // important to override qrcode inline width
|
width: 100% !important; // important to override qrcode inline width
|
||||||
height: 100% !important;
|
max-width: 100%;
|
||||||
max-width: 350px; // default width of <lnbits-qrcode> component
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode__image {
|
.qrcode__image {
|
||||||
position: absolute;
|
|
||||||
max-width: 52px;
|
|
||||||
width: 15%;
|
width: 15%;
|
||||||
|
height: 15%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
left: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-18
@@ -3,16 +3,16 @@
|
|||||||
"vendor/moment.js",
|
"vendor/moment.js",
|
||||||
"vendor/underscore.js",
|
"vendor/underscore.js",
|
||||||
"vendor/axios.js",
|
"vendor/axios.js",
|
||||||
"vendor/vue.global.prod.js",
|
"vendor/vue.js",
|
||||||
"vendor/quasar.umd.prod.js",
|
"vendor/vue-router.js",
|
||||||
"vendor/vuex.global.js",
|
"vendor/VueQrcodeReader.umd.js",
|
||||||
"vendor/vue-i18n.global.prod.js",
|
"vendor/vue-qrcode.js",
|
||||||
"vendor/vue-router.global.js",
|
"vendor/vuex.js",
|
||||||
"vendor/vue-qrcode-reader.umd.js",
|
"vendor/quasar.ie.polyfills.umd.min.js",
|
||||||
"vendor/qrcode.vue.browser.js",
|
"vendor/quasar.umd.js",
|
||||||
"vendor/chart.umd.js",
|
"vendor/Chart.bundle.js",
|
||||||
|
"vendor/vue-i18n.js",
|
||||||
"vendor/showdown.js",
|
"vendor/showdown.js",
|
||||||
"vendor/nostr.bundle.js",
|
|
||||||
"i18n/i18n.js",
|
"i18n/i18n.js",
|
||||||
"i18n/de.js",
|
"i18n/de.js",
|
||||||
"i18n/en.js",
|
"i18n/en.js",
|
||||||
@@ -34,16 +34,14 @@
|
|||||||
"i18n/kr.js",
|
"i18n/kr.js",
|
||||||
"i18n/fi.js",
|
"i18n/fi.js",
|
||||||
"js/base.js",
|
"js/base.js",
|
||||||
|
"js/components.js",
|
||||||
|
"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/event-reactions.js",
|
"js/event-reactions.js",
|
||||||
"js/bolt11-decoder.js"
|
"js/bolt11-decoder.js"
|
||||||
],
|
],
|
||||||
"components": [
|
"css": ["vendor/quasar.css", "vendor/Chart.css", "css/base.css"]
|
||||||
"js/components/lnbits-funding-sources.js",
|
|
||||||
"js/components/extension-settings.js",
|
|
||||||
"js/components/payment-list.js",
|
|
||||||
"js/components/payment-chart.js",
|
|
||||||
"js/components.js",
|
|
||||||
"js/init-app.js"
|
|
||||||
],
|
|
||||||
"css": ["vendor/quasar.css", "css/base.css"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+131
-179
@@ -1,4 +1,4 @@
|
|||||||
// Axios v1.7.7 Copyright (c) 2024 Matt Zabriskie and contributors
|
// Axios v1.7.5 Copyright (c) 2024 Matt Zabriskie and contributors
|
||||||
(function (global, factory) {
|
(function (global, factory) {
|
||||||
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
||||||
typeof define === 'function' && define.amd ? define(factory) :
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
@@ -3093,42 +3093,38 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
var composeSignals = function composeSignals(signals, timeout) {
|
var composeSignals = function composeSignals(signals, timeout) {
|
||||||
var _signals = signals = signals ? signals.filter(Boolean) : [],
|
var controller = new AbortController();
|
||||||
length = _signals.length;
|
var aborted;
|
||||||
if (timeout || length) {
|
var onabort = function onabort(cancel) {
|
||||||
var controller = new AbortController();
|
if (!aborted) {
|
||||||
var aborted;
|
aborted = true;
|
||||||
var onabort = function onabort(reason) {
|
unsubscribe();
|
||||||
if (!aborted) {
|
var err = cancel instanceof Error ? cancel : this.reason;
|
||||||
aborted = true;
|
controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err));
|
||||||
unsubscribe();
|
}
|
||||||
var err = reason instanceof Error ? reason : this.reason;
|
};
|
||||||
controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err));
|
var timer = timeout && setTimeout(function () {
|
||||||
}
|
onabort(new AxiosError("timeout ".concat(timeout, " of ms exceeded"), AxiosError.ETIMEDOUT));
|
||||||
};
|
}, timeout);
|
||||||
var timer = timeout && setTimeout(function () {
|
var unsubscribe = function unsubscribe() {
|
||||||
|
if (signals) {
|
||||||
|
timer && clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
onabort(new AxiosError("timeout ".concat(timeout, " of ms exceeded"), AxiosError.ETIMEDOUT));
|
signals.forEach(function (signal) {
|
||||||
}, timeout);
|
signal && (signal.removeEventListener ? signal.removeEventListener('abort', onabort) : signal.unsubscribe(onabort));
|
||||||
var unsubscribe = function unsubscribe() {
|
});
|
||||||
if (signals) {
|
signals = null;
|
||||||
timer && clearTimeout(timer);
|
}
|
||||||
timer = null;
|
};
|
||||||
signals.forEach(function (signal) {
|
signals.forEach(function (signal) {
|
||||||
signal.unsubscribe ? signal.unsubscribe(onabort) : signal.removeEventListener('abort', onabort);
|
return signal && signal.addEventListener && signal.addEventListener('abort', onabort);
|
||||||
});
|
});
|
||||||
signals = null;
|
var signal = controller.signal;
|
||||||
}
|
signal.unsubscribe = unsubscribe;
|
||||||
};
|
return [signal, function () {
|
||||||
signals.forEach(function (signal) {
|
timer && clearTimeout(timer);
|
||||||
return signal.addEventListener('abort', onabort);
|
timer = null;
|
||||||
});
|
}];
|
||||||
var signal = controller.signal;
|
|
||||||
signal.unsubscribe = function () {
|
|
||||||
return utils$1.asap(unsubscribe);
|
|
||||||
};
|
|
||||||
return signal;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
var composeSignals$1 = composeSignals;
|
var composeSignals$1 = composeSignals;
|
||||||
|
|
||||||
@@ -3167,7 +3163,7 @@
|
|||||||
}, streamChunk);
|
}, streamChunk);
|
||||||
});
|
});
|
||||||
var readBytes = /*#__PURE__*/function () {
|
var readBytes = /*#__PURE__*/function () {
|
||||||
var _ref = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(iterable, chunkSize) {
|
var _ref = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(iterable, chunkSize, encode) {
|
||||||
var _iteratorAbruptCompletion, _didIteratorError, _iteratorError, _iterator, _step, chunk;
|
var _iteratorAbruptCompletion, _didIteratorError, _iteratorError, _iterator, _step, chunk;
|
||||||
return _regeneratorRuntime().wrap(function _callee$(_context2) {
|
return _regeneratorRuntime().wrap(function _callee$(_context2) {
|
||||||
while (1) switch (_context2.prev = _context2.next) {
|
while (1) switch (_context2.prev = _context2.next) {
|
||||||
@@ -3175,111 +3171,82 @@
|
|||||||
_iteratorAbruptCompletion = false;
|
_iteratorAbruptCompletion = false;
|
||||||
_didIteratorError = false;
|
_didIteratorError = false;
|
||||||
_context2.prev = 2;
|
_context2.prev = 2;
|
||||||
_iterator = _asyncIterator(readStream(iterable));
|
_iterator = _asyncIterator(iterable);
|
||||||
case 4:
|
case 4:
|
||||||
_context2.next = 6;
|
_context2.next = 6;
|
||||||
return _awaitAsyncGenerator(_iterator.next());
|
return _awaitAsyncGenerator(_iterator.next());
|
||||||
case 6:
|
case 6:
|
||||||
if (!(_iteratorAbruptCompletion = !(_step = _context2.sent).done)) {
|
if (!(_iteratorAbruptCompletion = !(_step = _context2.sent).done)) {
|
||||||
_context2.next = 12;
|
_context2.next = 27;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
chunk = _step.value;
|
chunk = _step.value;
|
||||||
return _context2.delegateYield(_asyncGeneratorDelegate(_asyncIterator(streamChunk(chunk, chunkSize))), "t0", 9);
|
_context2.t0 = _asyncGeneratorDelegate;
|
||||||
case 9:
|
_context2.t1 = _asyncIterator;
|
||||||
|
_context2.t2 = streamChunk;
|
||||||
|
if (!ArrayBuffer.isView(chunk)) {
|
||||||
|
_context2.next = 15;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_context2.t3 = chunk;
|
||||||
|
_context2.next = 18;
|
||||||
|
break;
|
||||||
|
case 15:
|
||||||
|
_context2.next = 17;
|
||||||
|
return _awaitAsyncGenerator(encode(String(chunk)));
|
||||||
|
case 17:
|
||||||
|
_context2.t3 = _context2.sent;
|
||||||
|
case 18:
|
||||||
|
_context2.t4 = _context2.t3;
|
||||||
|
_context2.t5 = chunkSize;
|
||||||
|
_context2.t6 = (0, _context2.t2)(_context2.t4, _context2.t5);
|
||||||
|
_context2.t7 = (0, _context2.t1)(_context2.t6);
|
||||||
|
_context2.t8 = _awaitAsyncGenerator;
|
||||||
|
return _context2.delegateYield((0, _context2.t0)(_context2.t7, _context2.t8), "t9", 24);
|
||||||
|
case 24:
|
||||||
_iteratorAbruptCompletion = false;
|
_iteratorAbruptCompletion = false;
|
||||||
_context2.next = 4;
|
_context2.next = 4;
|
||||||
break;
|
break;
|
||||||
case 12:
|
case 27:
|
||||||
_context2.next = 18;
|
_context2.next = 33;
|
||||||
break;
|
break;
|
||||||
case 14:
|
case 29:
|
||||||
_context2.prev = 14;
|
_context2.prev = 29;
|
||||||
_context2.t1 = _context2["catch"](2);
|
_context2.t10 = _context2["catch"](2);
|
||||||
_didIteratorError = true;
|
_didIteratorError = true;
|
||||||
_iteratorError = _context2.t1;
|
_iteratorError = _context2.t10;
|
||||||
case 18:
|
case 33:
|
||||||
_context2.prev = 18;
|
_context2.prev = 33;
|
||||||
_context2.prev = 19;
|
_context2.prev = 34;
|
||||||
if (!(_iteratorAbruptCompletion && _iterator["return"] != null)) {
|
if (!(_iteratorAbruptCompletion && _iterator["return"] != null)) {
|
||||||
_context2.next = 23;
|
_context2.next = 38;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_context2.next = 23;
|
_context2.next = 38;
|
||||||
return _awaitAsyncGenerator(_iterator["return"]());
|
return _awaitAsyncGenerator(_iterator["return"]());
|
||||||
case 23:
|
case 38:
|
||||||
_context2.prev = 23;
|
_context2.prev = 38;
|
||||||
if (!_didIteratorError) {
|
if (!_didIteratorError) {
|
||||||
_context2.next = 26;
|
_context2.next = 41;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
throw _iteratorError;
|
throw _iteratorError;
|
||||||
case 26:
|
case 41:
|
||||||
return _context2.finish(23);
|
return _context2.finish(38);
|
||||||
case 27:
|
case 42:
|
||||||
return _context2.finish(18);
|
return _context2.finish(33);
|
||||||
case 28:
|
case 43:
|
||||||
case "end":
|
case "end":
|
||||||
return _context2.stop();
|
return _context2.stop();
|
||||||
}
|
}
|
||||||
}, _callee, null, [[2, 14, 18, 28], [19,, 23, 27]]);
|
}, _callee, null, [[2, 29, 33, 43], [34,, 38, 42]]);
|
||||||
}));
|
}));
|
||||||
return function readBytes(_x, _x2) {
|
return function readBytes(_x, _x2, _x3) {
|
||||||
return _ref.apply(this, arguments);
|
return _ref.apply(this, arguments);
|
||||||
};
|
};
|
||||||
}();
|
}();
|
||||||
var readStream = /*#__PURE__*/function () {
|
var trackStream = function trackStream(stream, chunkSize, onProgress, onFinish, encode) {
|
||||||
var _ref2 = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(stream) {
|
var iterator = readBytes(stream, chunkSize, encode);
|
||||||
var reader, _yield$_awaitAsyncGen, done, value;
|
|
||||||
return _regeneratorRuntime().wrap(function _callee2$(_context3) {
|
|
||||||
while (1) switch (_context3.prev = _context3.next) {
|
|
||||||
case 0:
|
|
||||||
if (!stream[Symbol.asyncIterator]) {
|
|
||||||
_context3.next = 3;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return _context3.delegateYield(_asyncGeneratorDelegate(_asyncIterator(stream)), "t0", 2);
|
|
||||||
case 2:
|
|
||||||
return _context3.abrupt("return");
|
|
||||||
case 3:
|
|
||||||
reader = stream.getReader();
|
|
||||||
_context3.prev = 4;
|
|
||||||
case 5:
|
|
||||||
_context3.next = 7;
|
|
||||||
return _awaitAsyncGenerator(reader.read());
|
|
||||||
case 7:
|
|
||||||
_yield$_awaitAsyncGen = _context3.sent;
|
|
||||||
done = _yield$_awaitAsyncGen.done;
|
|
||||||
value = _yield$_awaitAsyncGen.value;
|
|
||||||
if (!done) {
|
|
||||||
_context3.next = 12;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return _context3.abrupt("break", 16);
|
|
||||||
case 12:
|
|
||||||
_context3.next = 14;
|
|
||||||
return value;
|
|
||||||
case 14:
|
|
||||||
_context3.next = 5;
|
|
||||||
break;
|
|
||||||
case 16:
|
|
||||||
_context3.prev = 16;
|
|
||||||
_context3.next = 19;
|
|
||||||
return _awaitAsyncGenerator(reader.cancel());
|
|
||||||
case 19:
|
|
||||||
return _context3.finish(16);
|
|
||||||
case 20:
|
|
||||||
case "end":
|
|
||||||
return _context3.stop();
|
|
||||||
}
|
|
||||||
}, _callee2, null, [[4,, 16, 20]]);
|
|
||||||
}));
|
|
||||||
return function readStream(_x3) {
|
|
||||||
return _ref2.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}();
|
|
||||||
var trackStream = function trackStream(stream, chunkSize, onProgress, onFinish) {
|
|
||||||
var iterator = readBytes(stream, chunkSize);
|
|
||||||
var bytes = 0;
|
var bytes = 0;
|
||||||
var done;
|
var done;
|
||||||
var _onFinish = function _onFinish(e) {
|
var _onFinish = function _onFinish(e) {
|
||||||
@@ -3290,25 +3257,25 @@
|
|||||||
};
|
};
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
pull: function pull(controller) {
|
pull: function pull(controller) {
|
||||||
return _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3() {
|
return _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2() {
|
||||||
var _yield$iterator$next, _done, value, len, loadedBytes;
|
var _yield$iterator$next, _done, value, len, loadedBytes;
|
||||||
return _regeneratorRuntime().wrap(function _callee3$(_context4) {
|
return _regeneratorRuntime().wrap(function _callee2$(_context3) {
|
||||||
while (1) switch (_context4.prev = _context4.next) {
|
while (1) switch (_context3.prev = _context3.next) {
|
||||||
case 0:
|
case 0:
|
||||||
_context4.prev = 0;
|
_context3.prev = 0;
|
||||||
_context4.next = 3;
|
_context3.next = 3;
|
||||||
return iterator.next();
|
return iterator.next();
|
||||||
case 3:
|
case 3:
|
||||||
_yield$iterator$next = _context4.sent;
|
_yield$iterator$next = _context3.sent;
|
||||||
_done = _yield$iterator$next.done;
|
_done = _yield$iterator$next.done;
|
||||||
value = _yield$iterator$next.value;
|
value = _yield$iterator$next.value;
|
||||||
if (!_done) {
|
if (!_done) {
|
||||||
_context4.next = 10;
|
_context3.next = 10;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_onFinish();
|
_onFinish();
|
||||||
controller.close();
|
controller.close();
|
||||||
return _context4.abrupt("return");
|
return _context3.abrupt("return");
|
||||||
case 10:
|
case 10:
|
||||||
len = value.byteLength;
|
len = value.byteLength;
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
@@ -3316,18 +3283,18 @@
|
|||||||
onProgress(loadedBytes);
|
onProgress(loadedBytes);
|
||||||
}
|
}
|
||||||
controller.enqueue(new Uint8Array(value));
|
controller.enqueue(new Uint8Array(value));
|
||||||
_context4.next = 19;
|
_context3.next = 19;
|
||||||
break;
|
break;
|
||||||
case 15:
|
case 15:
|
||||||
_context4.prev = 15;
|
_context3.prev = 15;
|
||||||
_context4.t0 = _context4["catch"](0);
|
_context3.t0 = _context3["catch"](0);
|
||||||
_onFinish(_context4.t0);
|
_onFinish(_context3.t0);
|
||||||
throw _context4.t0;
|
throw _context3.t0;
|
||||||
case 19:
|
case 19:
|
||||||
case "end":
|
case "end":
|
||||||
return _context4.stop();
|
return _context3.stop();
|
||||||
}
|
}
|
||||||
}, _callee3, null, [[0, 15]]);
|
}, _callee2, null, [[0, 15]]);
|
||||||
}))();
|
}))();
|
||||||
},
|
},
|
||||||
cancel: function cancel(reason) {
|
cancel: function cancel(reason) {
|
||||||
@@ -3410,7 +3377,6 @@
|
|||||||
}(new Response());
|
}(new Response());
|
||||||
var getBodyLength = /*#__PURE__*/function () {
|
var getBodyLength = /*#__PURE__*/function () {
|
||||||
var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) {
|
var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) {
|
||||||
var _request;
|
|
||||||
return _regeneratorRuntime().wrap(function _callee2$(_context2) {
|
return _regeneratorRuntime().wrap(function _callee2$(_context2) {
|
||||||
while (1) switch (_context2.prev = _context2.next) {
|
while (1) switch (_context2.prev = _context2.next) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -3427,36 +3393,32 @@
|
|||||||
return _context2.abrupt("return", body.size);
|
return _context2.abrupt("return", body.size);
|
||||||
case 4:
|
case 4:
|
||||||
if (!utils$1.isSpecCompliantForm(body)) {
|
if (!utils$1.isSpecCompliantForm(body)) {
|
||||||
_context2.next = 9;
|
_context2.next = 8;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_request = new Request(platform.origin, {
|
_context2.next = 7;
|
||||||
method: 'POST',
|
return new Request(body).arrayBuffer();
|
||||||
body: body
|
case 7:
|
||||||
});
|
|
||||||
_context2.next = 8;
|
|
||||||
return _request.arrayBuffer();
|
|
||||||
case 8:
|
|
||||||
return _context2.abrupt("return", _context2.sent.byteLength);
|
return _context2.abrupt("return", _context2.sent.byteLength);
|
||||||
case 9:
|
case 8:
|
||||||
if (!(utils$1.isArrayBufferView(body) || utils$1.isArrayBuffer(body))) {
|
if (!(utils$1.isArrayBufferView(body) || utils$1.isArrayBuffer(body))) {
|
||||||
_context2.next = 11;
|
_context2.next = 10;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return _context2.abrupt("return", body.byteLength);
|
return _context2.abrupt("return", body.byteLength);
|
||||||
case 11:
|
case 10:
|
||||||
if (utils$1.isURLSearchParams(body)) {
|
if (utils$1.isURLSearchParams(body)) {
|
||||||
body = body + '';
|
body = body + '';
|
||||||
}
|
}
|
||||||
if (!utils$1.isString(body)) {
|
if (!utils$1.isString(body)) {
|
||||||
_context2.next = 16;
|
_context2.next = 15;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_context2.next = 15;
|
_context2.next = 14;
|
||||||
return encodeText(body);
|
return encodeText(body);
|
||||||
case 15:
|
case 14:
|
||||||
return _context2.abrupt("return", _context2.sent.byteLength);
|
return _context2.abrupt("return", _context2.sent.byteLength);
|
||||||
case 16:
|
case 15:
|
||||||
case "end":
|
case "end":
|
||||||
return _context2.stop();
|
return _context2.stop();
|
||||||
}
|
}
|
||||||
@@ -3486,15 +3448,18 @@
|
|||||||
}();
|
}();
|
||||||
var fetchAdapter = isFetchSupported && ( /*#__PURE__*/function () {
|
var fetchAdapter = isFetchSupported && ( /*#__PURE__*/function () {
|
||||||
var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) {
|
var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) {
|
||||||
var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, composedSignal, request, unsubscribe, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, response, isStreamResponse, options, responseContentLength, _ref5, _ref6, _onProgress, _flush, responseData;
|
var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, _ref5, _ref6, composedSignal, stopTimeout, finished, request, onFinish, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, response, isStreamResponse, options, responseContentLength, _ref7, _ref8, _onProgress, _flush, responseData;
|
||||||
return _regeneratorRuntime().wrap(function _callee4$(_context4) {
|
return _regeneratorRuntime().wrap(function _callee4$(_context4) {
|
||||||
while (1) switch (_context4.prev = _context4.next) {
|
while (1) switch (_context4.prev = _context4.next) {
|
||||||
case 0:
|
case 0:
|
||||||
_resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions;
|
_resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions;
|
||||||
responseType = responseType ? (responseType + '').toLowerCase() : 'text';
|
responseType = responseType ? (responseType + '').toLowerCase() : 'text';
|
||||||
composedSignal = composeSignals$1([signal, cancelToken && cancelToken.toAbortSignal()], timeout);
|
_ref5 = signal || cancelToken || timeout ? composeSignals$1([signal, cancelToken], timeout) : [], _ref6 = _slicedToArray(_ref5, 2), composedSignal = _ref6[0], stopTimeout = _ref6[1];
|
||||||
unsubscribe = composedSignal && composedSignal.unsubscribe && function () {
|
onFinish = function onFinish() {
|
||||||
composedSignal.unsubscribe();
|
!finished && setTimeout(function () {
|
||||||
|
composedSignal && composedSignal.unsubscribe();
|
||||||
|
});
|
||||||
|
finished = true;
|
||||||
};
|
};
|
||||||
_context4.prev = 4;
|
_context4.prev = 4;
|
||||||
_context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head';
|
_context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head';
|
||||||
@@ -3522,7 +3487,7 @@
|
|||||||
}
|
}
|
||||||
if (_request.body) {
|
if (_request.body) {
|
||||||
_progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1];
|
_progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1];
|
||||||
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush);
|
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush, encodeText);
|
||||||
}
|
}
|
||||||
case 15:
|
case 15:
|
||||||
if (!utils$1.isString(withCredentials)) {
|
if (!utils$1.isString(withCredentials)) {
|
||||||
@@ -3545,25 +3510,26 @@
|
|||||||
case 20:
|
case 20:
|
||||||
response = _context4.sent;
|
response = _context4.sent;
|
||||||
isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response');
|
isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response');
|
||||||
if (supportsResponseStream && (onDownloadProgress || isStreamResponse && unsubscribe)) {
|
if (supportsResponseStream && (onDownloadProgress || isStreamResponse)) {
|
||||||
options = {};
|
options = {};
|
||||||
['status', 'statusText', 'headers'].forEach(function (prop) {
|
['status', 'statusText', 'headers'].forEach(function (prop) {
|
||||||
options[prop] = response[prop];
|
options[prop] = response[prop];
|
||||||
});
|
});
|
||||||
responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length'));
|
responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length'));
|
||||||
_ref5 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref6 = _slicedToArray(_ref5, 2), _onProgress = _ref6[0], _flush = _ref6[1];
|
_ref7 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref8 = _slicedToArray(_ref7, 2), _onProgress = _ref8[0], _flush = _ref8[1];
|
||||||
response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () {
|
response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () {
|
||||||
_flush && _flush();
|
_flush && _flush();
|
||||||
unsubscribe && unsubscribe();
|
isStreamResponse && onFinish();
|
||||||
}), options);
|
}, encodeText), options);
|
||||||
}
|
}
|
||||||
responseType = responseType || 'text';
|
responseType = responseType || 'text';
|
||||||
_context4.next = 26;
|
_context4.next = 26;
|
||||||
return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config);
|
return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config);
|
||||||
case 26:
|
case 26:
|
||||||
responseData = _context4.sent;
|
responseData = _context4.sent;
|
||||||
!isStreamResponse && unsubscribe && unsubscribe();
|
!isStreamResponse && onFinish();
|
||||||
_context4.next = 30;
|
stopTimeout && stopTimeout();
|
||||||
|
_context4.next = 31;
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
settle(resolve, reject, {
|
settle(resolve, reject, {
|
||||||
data: responseData,
|
data: responseData,
|
||||||
@@ -3574,26 +3540,26 @@
|
|||||||
request: request
|
request: request
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
case 30:
|
case 31:
|
||||||
return _context4.abrupt("return", _context4.sent);
|
return _context4.abrupt("return", _context4.sent);
|
||||||
case 33:
|
case 34:
|
||||||
_context4.prev = 33;
|
_context4.prev = 34;
|
||||||
_context4.t2 = _context4["catch"](4);
|
_context4.t2 = _context4["catch"](4);
|
||||||
unsubscribe && unsubscribe();
|
onFinish();
|
||||||
if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /fetch/i.test(_context4.t2.message))) {
|
if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /fetch/i.test(_context4.t2.message))) {
|
||||||
_context4.next = 38;
|
_context4.next = 39;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), {
|
throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), {
|
||||||
cause: _context4.t2.cause || _context4.t2
|
cause: _context4.t2.cause || _context4.t2
|
||||||
});
|
});
|
||||||
case 38:
|
|
||||||
throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request);
|
|
||||||
case 39:
|
case 39:
|
||||||
|
throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request);
|
||||||
|
case 40:
|
||||||
case "end":
|
case "end":
|
||||||
return _context4.stop();
|
return _context4.stop();
|
||||||
}
|
}
|
||||||
}, _callee4, null, [[4, 33]]);
|
}, _callee4, null, [[4, 34]]);
|
||||||
}));
|
}));
|
||||||
return function (_x5) {
|
return function (_x5) {
|
||||||
return _ref4.apply(this, arguments);
|
return _ref4.apply(this, arguments);
|
||||||
@@ -3717,7 +3683,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var VERSION = "1.7.7";
|
var VERSION = "1.7.5";
|
||||||
|
|
||||||
var validators$1 = {};
|
var validators$1 = {};
|
||||||
|
|
||||||
@@ -4098,20 +4064,6 @@
|
|||||||
this._listeners.splice(index, 1);
|
this._listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
key: "toAbortSignal",
|
|
||||||
value: function toAbortSignal() {
|
|
||||||
var _this = this;
|
|
||||||
var controller = new AbortController();
|
|
||||||
var abort = function abort(err) {
|
|
||||||
controller.abort(err);
|
|
||||||
};
|
|
||||||
this.subscribe(abort);
|
|
||||||
controller.signal.unsubscribe = function () {
|
|
||||||
return _this.unsubscribe(abort);
|
|
||||||
};
|
|
||||||
return controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an object that contains a new `CancelToken` and a function that, when called,
|
* Returns an object that contains a new `CancelToken` and a function that, when called,
|
||||||
|
|||||||
Vendored
-14
File diff suppressed because one or more lines are too long
Vendored
-6342
File diff suppressed because it is too large
Load Diff
-1099
File diff suppressed because it is too large
Load Diff
Vendored
+3148
-3108
File diff suppressed because it is too large
Load Diff
-127
File diff suppressed because one or more lines are too long
Vendored
+8
-13
@@ -7,13 +7,13 @@
|
|||||||
exports.noConflict = function () { global._ = current; return exports; };
|
exports.noConflict = function () { global._ = current; return exports; };
|
||||||
}()));
|
}()));
|
||||||
}(this, (function () {
|
}(this, (function () {
|
||||||
// Underscore.js 1.13.7
|
// Underscore.js 1.13.6
|
||||||
// https://underscorejs.org
|
// https://underscorejs.org
|
||||||
// (c) 2009-2024 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
|
// (c) 2009-2022 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
|
||||||
// Underscore may be freely distributed under the MIT license.
|
// Underscore may be freely distributed under the MIT license.
|
||||||
|
|
||||||
// Current version.
|
// Current version.
|
||||||
var VERSION = '1.13.7';
|
var VERSION = '1.13.6';
|
||||||
|
|
||||||
// Establish the root object, `window` (`self`) in the browser, `global`
|
// Establish the root object, `window` (`self`) in the browser, `global`
|
||||||
// on the server, or `this` in some virtual machines. We use `self`
|
// on the server, or `this` in some virtual machines. We use `self`
|
||||||
@@ -150,11 +150,8 @@
|
|||||||
// In IE 10 - Edge 13, `DataView` has string tag `'[object Object]'`.
|
// In IE 10 - Edge 13, `DataView` has string tag `'[object Object]'`.
|
||||||
// In IE 11, the most common among them, this problem also applies to
|
// In IE 11, the most common among them, this problem also applies to
|
||||||
// `Map`, `WeakMap` and `Set`.
|
// `Map`, `WeakMap` and `Set`.
|
||||||
// Also, there are cases where an application can override the native
|
var hasStringTagBug = (
|
||||||
// `DataView` object, in cases like that we can't use the constructor
|
supportsDataView && hasObjectTag(new DataView(new ArrayBuffer(8)))
|
||||||
// safely and should just rely on alternate `DataView` checks
|
|
||||||
var hasDataViewBug = (
|
|
||||||
supportsDataView && (!/\[native code\]/.test(String(DataView)) || hasObjectTag(new DataView(new ArrayBuffer(8))))
|
|
||||||
),
|
),
|
||||||
isIE11 = (typeof Map !== 'undefined' && hasObjectTag(new Map));
|
isIE11 = (typeof Map !== 'undefined' && hasObjectTag(new Map));
|
||||||
|
|
||||||
@@ -162,13 +159,11 @@
|
|||||||
|
|
||||||
// In IE 10 - Edge 13, we need a different heuristic
|
// In IE 10 - Edge 13, we need a different heuristic
|
||||||
// to determine whether an object is a `DataView`.
|
// to determine whether an object is a `DataView`.
|
||||||
// Also, in cases where the native `DataView` is
|
function ie10IsDataView(obj) {
|
||||||
// overridden we can't rely on the tag itself.
|
|
||||||
function alternateIsDataView(obj) {
|
|
||||||
return obj != null && isFunction$1(obj.getInt8) && isArrayBuffer(obj.buffer);
|
return obj != null && isFunction$1(obj.getInt8) && isArrayBuffer(obj.buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDataView$1 = (hasDataViewBug ? alternateIsDataView : isDataView);
|
var isDataView$1 = (hasStringTagBug ? ie10IsDataView : isDataView);
|
||||||
|
|
||||||
// Is a given value an array?
|
// Is a given value an array?
|
||||||
// Delegates to ECMA5's native `Array.isArray`.
|
// Delegates to ECMA5's native `Array.isArray`.
|
||||||
@@ -381,7 +376,7 @@
|
|||||||
var className = toString.call(a);
|
var className = toString.call(a);
|
||||||
if (className !== toString.call(b)) return false;
|
if (className !== toString.call(b)) return false;
|
||||||
// Work around a bug in IE 10 - Edge 13.
|
// Work around a bug in IE 10 - Edge 13.
|
||||||
if (hasDataViewBug && className == '[object Object]' && isDataView$1(a)) {
|
if (hasStringTagBug && className == '[object Object]' && isDataView$1(a)) {
|
||||||
if (!isDataView$1(b)) return false;
|
if (!isDataView$1(b)) return false;
|
||||||
className = tagDataView;
|
className = tagDataView;
|
||||||
}
|
}
|
||||||
|
|||||||
-6690
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+5505
-109
File diff suppressed because it is too large
Load Diff
-3934
File diff suppressed because it is too large
Load Diff
-9
File diff suppressed because one or more lines are too long
Vendored
-1514
File diff suppressed because it is too large
Load Diff
+19
-11
@@ -20,7 +20,8 @@ from lnbits.core.crud import (
|
|||||||
delete_webpush_subscriptions,
|
delete_webpush_subscriptions,
|
||||||
get_payments,
|
get_payments,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
update_payment,
|
update_payment_details,
|
||||||
|
update_payment_status,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import Payment, PaymentState
|
from lnbits.core.models import Payment, PaymentState
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -180,14 +181,17 @@ async def check_pending_payments():
|
|||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
prefix = f"payment ({i+1} / {count})"
|
prefix = f"payment ({i+1} / {count})"
|
||||||
if status.failed:
|
if status.failed:
|
||||||
payment.status = PaymentState.FAILED
|
await update_payment_status(
|
||||||
await update_payment(payment)
|
payment.checking_id, status=PaymentState.FAILED
|
||||||
|
)
|
||||||
logger.debug(f"{prefix} failed {payment.checking_id}")
|
logger.debug(f"{prefix} failed {payment.checking_id}")
|
||||||
elif status.success:
|
elif status.success:
|
||||||
payment.fee = status.fee_msat or 0
|
await update_payment_details(
|
||||||
payment.preimage = status.preimage
|
checking_id=payment.checking_id,
|
||||||
payment.status = PaymentState.SUCCESS
|
fee=status.fee_msat,
|
||||||
await update_payment(payment)
|
preimage=status.preimage,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
)
|
||||||
logger.debug(f"{prefix} success {payment.checking_id}")
|
logger.debug(f"{prefix} success {payment.checking_id}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"{prefix} pending {payment.checking_id}")
|
logger.debug(f"{prefix} pending {payment.checking_id}")
|
||||||
@@ -207,10 +211,14 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
|
|||||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||||
if payment and payment.is_in:
|
if payment and payment.is_in:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
payment.fee = status.fee_msat or 0
|
await update_payment_details(
|
||||||
payment.preimage = status.preimage
|
checking_id=payment.checking_id,
|
||||||
payment.status = PaymentState.SUCCESS
|
fee=status.fee_msat,
|
||||||
await update_payment(payment)
|
preimage=status.preimage,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
)
|
||||||
|
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||||
|
assert payment, "updated payment not found"
|
||||||
internal = "internal" if is_internal else ""
|
internal = "internal" if is_internal else ""
|
||||||
logger.success(f"{internal} invoice {checking_id} settled")
|
logger.success(f"{internal} invoice {checking_id} settled")
|
||||||
for name, send_chan in invoice_listeners.items():
|
for name, send_chan in invoice_listeners.items():
|
||||||
|
|||||||
+190
-190
@@ -1,4 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{% for url in INCLUDED_CSS %}
|
{% for url in INCLUDED_CSS %}
|
||||||
@@ -29,204 +30,205 @@
|
|||||||
<link async="async" rel="manifest" href="{{ web_manifest }}" />
|
<link async="async" rel="manifest" href="{{ web_manifest }}" />
|
||||||
{% endif %} {% block head_scripts %}{% endblock %}
|
{% endif %} {% block head_scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body data-theme="bitcoin">
|
<body data-theme="bitcoin">
|
||||||
<div id="vue">
|
<q-layout id="vue" view="hHh lpR lfr" v-cloak>
|
||||||
<q-layout view="hHh lpR lfr" v-cloak>
|
<q-header bordered class="bg-marginal-bg">
|
||||||
<q-header bordered class="bg-marginal-bg">
|
<q-toolbar>
|
||||||
<q-toolbar>
|
{% block drawer_toggle %}
|
||||||
{% block drawer_toggle %}
|
<q-btn
|
||||||
<q-btn
|
dense
|
||||||
dense
|
flat
|
||||||
flat
|
round
|
||||||
round
|
icon="menu"
|
||||||
icon="menu"
|
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
></q-btn>
|
||||||
></q-btn>
|
{% endblock %}
|
||||||
{% endblock %}
|
<q-toolbar-title>
|
||||||
<q-toolbar-title>
|
{% block toolbar_title %}
|
||||||
{% block toolbar_title %}
|
<q-btn flat no-caps dense size="lg" type="a" href="/"
|
||||||
<q-btn flat no-caps dense size="lg" type="a" href="/"
|
>{% if USE_CUSTOM_LOGO %}
|
||||||
>{% if USE_CUSTOM_LOGO %}
|
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
||||||
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
|
||||||
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {%
|
%}
|
||||||
else %}
|
<span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn
|
||||||
<span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn
|
>{% endblock %} {% block toolbar_subtitle %}{%if user and
|
||||||
>{% endblock %} {% block toolbar_subtitle %}{%if user and
|
user.super_user%}
|
||||||
user.super_user%}
|
<q-badge align="middle">Super User</q-badge>
|
||||||
<q-badge align="middle">Super User</q-badge>
|
{% elif user and user.admin %}
|
||||||
{% elif user and user.admin %}
|
<q-badge align="middle">Admin User</q-badge>
|
||||||
<q-badge align="middle">Admin User</q-badge>
|
{%endif%}{% endblock %}
|
||||||
{%endif%}{% endblock %}
|
</q-toolbar-title>
|
||||||
</q-toolbar-title>
|
{% block beta %} {% if VOIDWALLET %}
|
||||||
{% block beta %} {% if VOIDWALLET %}
|
<q-badge
|
||||||
<q-badge
|
v-text="$t('voidwallet_active')"
|
||||||
v-text="$t('voidwallet_active')"
|
color="red"
|
||||||
color="red"
|
class="q-mr-md gt-md"
|
||||||
class="q-mr-md gt-md"
|
>
|
||||||
>
|
</q-badge>
|
||||||
</q-badge>
|
{%endif%}
|
||||||
|
<q-badge
|
||||||
|
v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
|
||||||
|
v-show="$q.screen.gt.sm"
|
||||||
|
color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
|
||||||
|
class="q-mr-md"
|
||||||
|
label="{{LNBITS_CUSTOM_BADGE}}"
|
||||||
|
>
|
||||||
|
</q-badge>
|
||||||
|
{% if LNBITS_SERVICE_FEE > 0 %}
|
||||||
|
<q-badge
|
||||||
|
v-show="$q.screen.gt.sm"
|
||||||
|
v-if="g.user"
|
||||||
|
color="green"
|
||||||
|
class="q-mr-md"
|
||||||
|
>
|
||||||
|
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
|
||||||
|
<span
|
||||||
|
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
|
||||||
|
></span>
|
||||||
|
{%else%}
|
||||||
|
<span
|
||||||
|
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
|
||||||
|
></span>
|
||||||
{%endif%}
|
{%endif%}
|
||||||
<q-badge
|
<q-tooltip
|
||||||
v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
|
><span v-text='$t("service_fee_tooltip")'></span
|
||||||
v-show="$q.screen.gt.sm"
|
></q-tooltip>
|
||||||
color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
|
</q-badge>
|
||||||
class="q-mr-md"
|
|
||||||
label="{{LNBITS_CUSTOM_BADGE}}"
|
|
||||||
>
|
|
||||||
</q-badge>
|
|
||||||
{% if LNBITS_SERVICE_FEE > 0 %}
|
|
||||||
<q-badge
|
|
||||||
v-show="$q.screen.gt.sm"
|
|
||||||
v-if="g.user"
|
|
||||||
color="green"
|
|
||||||
class="q-mr-md"
|
|
||||||
>
|
|
||||||
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
|
|
||||||
<span
|
|
||||||
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
|
|
||||||
></span>
|
|
||||||
{%else%}
|
|
||||||
<span
|
|
||||||
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
|
|
||||||
></span>
|
|
||||||
{%endif%}
|
|
||||||
<q-tooltip
|
|
||||||
><span v-text='$t("service_fee_tooltip")'></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-badge>
|
|
||||||
|
|
||||||
{%endif%} {% endblock %}
|
{%endif%} {% endblock %}
|
||||||
<q-badge v-if="g.offline" color="red" class="q-mr-md">
|
<q-badge v-if="g.offline" color="red" class="q-mr-md">
|
||||||
<span>OFFLINE</span>
|
<span>OFFLINE</span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
|
|
||||||
<q-btn-dropdown
|
<q-btn-dropdown
|
||||||
v-if="isUserAuthorized"
|
v-if="isUserAuthorized"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
size="sm"
|
size="sm"
|
||||||
class="q-pl-sm"
|
class="q-pl-sm"
|
||||||
>
|
>
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<div>
|
<div>
|
||||||
{%if user and user.config and user.config.picture%}
|
{%if user and user.config and user.config.picture%}
|
||||||
<img src="{{user.config.picture}}" style="max-width: 32px" />
|
<img src="{{user.config.picture}}" style="max-width: 32px" />
|
||||||
{%else%}
|
{%else%}
|
||||||
<q-icon name="account_circle" />
|
<q-icon name="account_circle" />
|
||||||
{%endif%}
|
{%endif%}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item tag="a" href="/account" clickable v-close-popup
|
<q-item tag="a" href="/account" clickable v-close-popup
|
||||||
><q-item-section>
|
><q-item-section>
|
||||||
<q-icon name="person" />
|
<q-icon name="person" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
<span v-text="$t('my_account')"></span>
|
<span v-text="$t('my_account')"></span>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label> </q-item-label>
|
<q-item-label> </q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-item clickable v-close-popup @click="logout"
|
<q-item clickable v-close-popup @click="logout"
|
||||||
><q-item-section>
|
><q-item-section>
|
||||||
<q-icon name="logout" />
|
<q-icon name="logout" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
<span v-text="$t('logout')"></span>
|
<span v-text="$t('logout')"></span>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label> </q-item-label>
|
<q-item-label> </q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
{% block drawer %}
|
{% block drawer %}
|
||||||
<q-drawer
|
<q-drawer
|
||||||
v-model="g.visibleDrawer"
|
v-model="g.visibleDrawer"
|
||||||
side="left"
|
side="left"
|
||||||
:width="($q.screen.lt.md) ? 260 : 230"
|
:width="($q.screen.lt.md) ? 260 : 230"
|
||||||
show-if-above
|
show-if-above
|
||||||
:elevated="$q.screen.lt.md"
|
:elevated="$q.screen.lt.md"
|
||||||
>
|
>
|
||||||
<lnbits-wallet-list :balance="balance"></lnbits-wallet-list>
|
<lnbits-wallet-list></lnbits-wallet-list>
|
||||||
|
|
||||||
<lnbits-manage
|
<lnbits-manage
|
||||||
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||||
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
||||||
></lnbits-manage>
|
></lnbits-manage>
|
||||||
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
{% endblock %} {% block page_container %}
|
{% endblock %} {% block page_container %}
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||||
{% block page %}{% endblock %}
|
{% block page %}{% endblock %}
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
{% endblock %} {% block footer %}
|
{% endblock %} {% block footer %}
|
||||||
|
|
||||||
<q-footer
|
<q-footer
|
||||||
class="bg-transparent q-px-lg q-py-md"
|
class="bg-transparent q-px-lg q-py-md"
|
||||||
:class="{'text-dark': !$q.dark.isActive}"
|
:class="{'text-dark': !$q.dark.isActive}"
|
||||||
>
|
>
|
||||||
<q-space class="q-py-lg lt-md"></q-space>
|
<q-space class="q-py-lg lt-md"></q-space>
|
||||||
<q-toolbar class="gt-sm">
|
<q-toolbar class="gt-sm">
|
||||||
<q-toolbar-title class="text-caption">
|
<q-toolbar-title class="text-caption">
|
||||||
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
|
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
|
||||||
<br />
|
<br />
|
||||||
<small
|
<small
|
||||||
v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'"
|
v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'"
|
||||||
></small>
|
></small>
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
<q-space></q-space>
|
<q-space></q-space>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
:color="($q.dark.isActive) ? 'white' : 'primary'"
|
:color="($q.dark.isActive) ? 'white' : 'primary'"
|
||||||
type="a"
|
type="a"
|
||||||
href="/docs"
|
href="/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span v-text="$t('api_docs')"></span>
|
<span v-text="$t('api_docs')"></span>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('view_swagger_docs')"></span
|
><span v-text="$t('view_swagger_docs')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
:color="($q.dark.isActive) ? 'white' : 'primary'"
|
:color="($q.dark.isActive) ? 'white' : 'primary'"
|
||||||
icon="code"
|
icon="code"
|
||||||
type="a"
|
type="a"
|
||||||
href="https://github.com/lnbits/lnbits"
|
href="https://github.com/lnbits/lnbits"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<q-tooltip><span v-text="$t('view_github')"></span></q-tooltip>
|
<q-tooltip><span v-text="$t('view_github')"></span></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-footer>
|
</q-footer>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</q-layout>
|
</q-layout>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include('components.vue') %} {% block vue_templates %}{% endblock %} {%
|
{% block vue_templates %}{% endblock %}
|
||||||
for url in INCLUDED_JS %}
|
<!---->
|
||||||
|
{% for url in INCLUDED_JS %}
|
||||||
<script src="{{ static_url_for('static', url) }}"></script>
|
<script src="{{ static_url_for('static', url) }}"></script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<!---->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
|
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
|
||||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||||
@@ -256,8 +258,6 @@
|
|||||||
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
|
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
{% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
|
{% block scripts %}{% endblock %}
|
||||||
<script src="{{ static_url_for('static', url) }}"></script>
|
|
||||||
{% endfor %}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user