Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8069c531d | ||
|
|
a9374d509c | ||
|
|
3e72788fc8 | ||
|
|
f48d24aca8 | ||
|
|
015262c9b3 | ||
|
|
e5e481f836 | ||
|
|
6c32ebf7e6 | ||
|
|
7c88702754 | ||
|
|
210b8a7c18 | ||
|
|
4f9a5090c2 | ||
|
|
c05122e5fb | ||
|
|
c054e47913 | ||
|
|
92d3269a85 | ||
|
|
183b84c167 | ||
|
|
60f50a71a2 | ||
|
|
c3252ce4dc | ||
|
|
c312d70e63 | ||
|
|
36fc911b88 | ||
|
|
672a5b3a4d | ||
|
|
c0b33560bb | ||
|
|
15b6b1d512 | ||
|
|
67c92a79cf | ||
|
|
0d4751d6e0 | ||
|
|
213bde61a8 | ||
|
|
59553a6e1e | ||
|
|
918add5e6e | ||
|
|
2375f7bcf8 | ||
|
|
5021570f68 | ||
|
|
0d91d5b5be | ||
|
|
49f02a5b77 | ||
|
|
95b9dabb60 | ||
|
|
bc0087ec36 | ||
|
|
8272147012 | ||
|
|
244de2ae86 | ||
|
|
ef371e303c | ||
|
|
5ba06d42d0 | ||
|
|
15984fa49b | ||
|
|
67dc096909 | ||
|
|
2daddf8b74 | ||
|
|
ae6cf47244 | ||
|
|
1488f580ff | ||
|
|
609c949394 | ||
|
|
c24d2f24cf | ||
|
|
0ba81ec719 | ||
|
|
5e03707be5 | ||
|
|
50f1111356 | ||
|
|
112a0f9698 | ||
|
|
75016d1a27 | ||
|
|
5b9f244a9f | ||
|
|
0d2ff2e663 | ||
|
|
bdfefc2506 | ||
|
|
2a9f471783 |
+88
-59
@@ -1,11 +1,95 @@
|
|||||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### .env ONLY SETTINGS ############
|
||||||
|
######################################
|
||||||
|
# The following settings are ONLY set in your .env file.
|
||||||
|
# They are NOT managed by the Admin UI and are not stored in the database.
|
||||||
|
|
||||||
|
# === Logging and Development ===
|
||||||
|
|
||||||
|
DEBUG=False
|
||||||
|
DEBUG_DATABASE=False
|
||||||
|
BUNDLE_ASSETS=True
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|
||||||
|
# === Admin Settings ===
|
||||||
|
|
||||||
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
|
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||||
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5000
|
||||||
|
# VERSION=
|
||||||
|
# USER_AGENT=
|
||||||
|
|
||||||
|
# === LNbits ===
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# which fundingsources are allowed in the admin ui
|
||||||
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||||
|
|
||||||
|
# uvicorn variable, allow https behind a proxy
|
||||||
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
|
FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# ID of the super user. The user ID must exist.
|
||||||
|
# SUPER_USER=""
|
||||||
|
|
||||||
|
# LNBITS_TITLE="LNbits API"
|
||||||
|
# LNBITS_PATH="folder/path"
|
||||||
|
|
||||||
|
# === Auth Configurations ===
|
||||||
|
|
||||||
|
# Secret Key: will default to the hash of the super user.
|
||||||
|
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
||||||
|
AUTH_SECRET_KEY=""
|
||||||
|
|
||||||
|
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||||
|
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### END .env ONLY SETTINGS ########
|
||||||
|
######################################
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
####### Auth Configurations ##########
|
####### Auth Configurations ##########
|
||||||
######################################
|
######################################
|
||||||
# Secret Key: will default to the hash of the super user.
|
|
||||||
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
|
||||||
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, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
@@ -16,14 +100,6 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
|||||||
########### Admin Settings ###########
|
########### Admin Settings ###########
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
|
||||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
|
||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
|
||||||
# The rest of the settings will be stored in your database and you will be able to change them
|
|
||||||
# only through the Admin UI.
|
|
||||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
|
||||||
LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||||
@@ -32,22 +108,14 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f
|
|||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
HOST=127.0.0.1
|
|
||||||
PORT=5000
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
########## Funding Source ############
|
########## Funding Source ############
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
|
||||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
|
||||||
|
|
||||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||||
LIGHTNING_INVOICE_EXPIRY=3600
|
LIGHTNING_INVOICE_EXPIRY=3600
|
||||||
@@ -195,11 +263,6 @@ KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
|||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
|
||||||
FORWARDED_ALLOW_IPS="*"
|
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
@@ -210,8 +273,7 @@ LNBITS_BLOCKED_IPS=""
|
|||||||
# if set new users will not be able to create accounts
|
# if set new users will not be able to create accounts
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
# ID of the super user. The user ID must exist.
|
|
||||||
# SUPER_USER=""
|
|
||||||
|
|
||||||
# Extensions only admin can access
|
# Extensions only admin can access
|
||||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||||
@@ -244,26 +306,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
|||||||
# Hides wallet api, extensions can choose to honor
|
# Hides wallet api, extensions can choose to honor
|
||||||
LNBITS_HIDE_API=false
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
|
||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# Path where extensions will be installed (defaults to `./lnbits/`).
|
|
||||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
|
||||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
|
||||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
|
||||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
|
||||||
# psycopg2 as an additional dependency
|
|
||||||
LNBITS_DATA_FOLDER="./data"
|
|
||||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
|
||||||
|
|
||||||
# the service fee (in percent)
|
# the service fee (in percent)
|
||||||
LNBITS_SERVICE_FEE=0.0
|
LNBITS_SERVICE_FEE=0.0
|
||||||
# the wallet where fees go to
|
# the wallet where fees go to
|
||||||
@@ -292,16 +334,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
|||||||
###### Logging and Development #######
|
###### Logging and Development #######
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
DEBUG=false
|
|
||||||
DEBUG_DATABASE=false
|
|
||||||
BUNDLE_ASSETS=true
|
|
||||||
|
|
||||||
# logging into LNBITS_DATA_FOLDER/logs/
|
|
||||||
ENABLE_LOG_TO_FILE=true
|
|
||||||
|
|
||||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
|
||||||
LOG_ROTATION="100 MB"
|
|
||||||
LOG_RETENTION="3 months"
|
|
||||||
|
|
||||||
# for database cleanup commands
|
|
||||||
# CLEANUP_WALLETS_DAYS=90
|
|
||||||
|
|||||||
@@ -21,29 +21,16 @@ runs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ inputs.python-version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
# cache poetry install via pip
|
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Set up Poetry
|
- name: Install uv
|
||||||
uses: abatilo/actions-poetry@v2
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|
||||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
poetry config virtualenvs.create true --local
|
|
||||||
poetry config virtualenvs.in-project true --local
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
|
||||||
with:
|
with:
|
||||||
path: ./.venv
|
enable-cache: true
|
||||||
key: venv-${{ hashFiles('poetry.lock') }}
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
- name: Install the project dependencies
|
- name: Install the project dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: uv sync --locked --all-extras --dev
|
||||||
poetry env use python${{ inputs.python-version }}
|
|
||||||
poetry install --all-extras
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ inputs.node-version }}
|
- name: Use Node.js ${{ inputs.node-version }}
|
||||||
if: ${{ (inputs.npm == 'true') }}
|
if: ${{ (inputs.npm == 'true') }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "poetry run pytest tests/api"
|
custom-pytest: "uv run pytest tests/api"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "poetry run pytest tests/wallets"
|
custom-pytest: "uv run pytest tests/wallets"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "poetry run pytest tests/unit"
|
custom-pytest: "uv run pytest tests/unit"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
python-version: ["3.10"]
|
python-version: ["3.10"]
|
||||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||||
with:
|
with:
|
||||||
custom-pytest: "poetry run pytest tests/regtest"
|
custom-pytest: "uv run pytest tests/regtest"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||||
run: |
|
run: |
|
||||||
poetry run lnbits &
|
uv run lnbits &
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
- name: setup java version
|
- name: setup java version
|
||||||
|
|||||||
@@ -53,3 +53,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
make: checkbundle
|
make: checkbundle
|
||||||
npm: true
|
npm: true
|
||||||
|
|
||||||
|
poetry:
|
||||||
|
uses: ./.github/workflows/poetry.yml
|
||||||
|
|||||||
@@ -14,18 +14,17 @@ on:
|
|||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'poetry.lock'
|
- 'uv.lock'
|
||||||
- '.github/workflows/nix.yml'
|
- '.github/workflows/nix.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'poetry.lock'
|
- 'uv.lock'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nix:
|
nix:
|
||||||
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build LNbits AppImage DMG
|
name: Build LNbits AppImage
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -11,56 +11,89 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Step 1: Checkout the repository
|
# Step 1: Checkout the repository
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Step 2: Install Dependencies
|
# Step 2: Set up Python (uv will still use this toolchain)
|
||||||
- name: Install Dependencies
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Step 3: Install system deps (fuse) + uv
|
||||||
|
- name: Install system deps and uv
|
||||||
run: |
|
run: |
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libfuse2
|
sudo apt-get install -y libfuse2
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 3: Clone LNbits Repository
|
# Optional: Cache uv + venv to speed up CI
|
||||||
- name: Clone LNbits
|
- name: Cache uv and venv
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/uv
|
||||||
|
.venv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||||
|
|
||||||
|
# Step 4: Prepare packaging tree and clone LNbits
|
||||||
|
- name: Prepare packaging & clone LNbits
|
||||||
run: |
|
run: |
|
||||||
mv .github/packaging packaging
|
mv .github/packaging packaging
|
||||||
mkdir -p packaging/linux/AppDir/usr
|
mkdir -p packaging/linux/AppDir/usr
|
||||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 4: Make the AppImage Asset
|
# Step 5: Build the LNbits binary with uv + PyInstaller
|
||||||
- name: Make Asset
|
- name: Build LNbits binary (uv + PyInstaller)
|
||||||
run: |
|
run: |
|
||||||
cd packaging/linux/AppDir/usr/lnbits
|
cd packaging/linux/AppDir/usr/lnbits
|
||||||
poetry install
|
|
||||||
poetry run pip install pyinstaller
|
# Install project deps into .venv using uv
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install PyInstaller into the same environment
|
||||||
|
uv pip install pyinstaller
|
||||||
|
|
||||||
# Build the LNbits binary
|
# Build the LNbits binary
|
||||||
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
|
uv run pyinstaller \
|
||||||
|
--onefile \
|
||||||
|
--name lnbits \
|
||||||
|
--hidden-import=embit \
|
||||||
|
--collect-all embit \
|
||||||
|
--collect-all lnbits \
|
||||||
|
--collect-all sqlalchemy \
|
||||||
|
--collect-all aiosqlite \
|
||||||
|
--hidden-import=passlib.handlers.bcrypt \
|
||||||
|
"$(uv run which lnbits)"
|
||||||
|
|
||||||
cd ../../../../..
|
cd ../../../../..
|
||||||
chmod +x packaging/linux/AppDir/AppRun
|
chmod +x packaging/linux/AppDir/AppRun
|
||||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||||
|
|
||||||
|
# Clean out non-dist content from the app dir to keep AppImage slim
|
||||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||||
! -name 'dist' \
|
! -name 'dist' \
|
||||||
! -name 'lnbits' \
|
! -name 'lnbits' \
|
||||||
-exec rm -rf {} +
|
-exec rm -rf {} +
|
||||||
|
|
||||||
|
# Build AppImage
|
||||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x appimagetool-x86_64.AppImage
|
chmod +x appimagetool-x86_64.AppImage
|
||||||
TAG_NAME=${{ github.event.release.tag_name }}
|
TAG_NAME=${{ github.event.release.tag_name }}
|
||||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||||
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
./appimagetool-x86_64.AppImage \
|
||||||
|
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||||
|
packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||||
chmod +x "$APPIMAGE_NAME"
|
chmod +x "$APPIMAGE_NAME"
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 5: Upload Linux Release Asset
|
# Step 6: Upload Linux Release Asset
|
||||||
- name: Upload Linux Release Asset
|
- name: Upload Linux Release Asset
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -69,4 +102,4 @@ jobs:
|
|||||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||||
asset_content_type: application/octet-stream
|
asset_content_type: application/octet-stream
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: poetry
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: "python version"
|
||||||
|
type: string
|
||||||
|
default: "3.10"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
poetry:
|
||||||
|
name: run poetry install to check lock file
|
||||||
|
runs-on: "ubuntu-24.04"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: set up python ${{ inputs.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
- name: install poetry
|
||||||
|
run: |
|
||||||
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
poetry install
|
||||||
@@ -32,6 +32,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -t lnbits/lnbits .
|
docker build -t lnbits/lnbits .
|
||||||
|
|
||||||
|
- uses: ./.github/actions/prepare
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
@@ -40,10 +44,6 @@ jobs:
|
|||||||
./tests
|
./tests
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
|
|
||||||
- uses: ./.github/actions/prepare
|
|
||||||
with:
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
uses: pavelzw/pytest-action@v2
|
uses: pavelzw/pytest-action@v2
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -61,3 +61,4 @@ dist
|
|||||||
|
|
||||||
# jetbrains
|
# jetbrains
|
||||||
.idea
|
.idea
|
||||||
|
.venv
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
|
|||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -14,16 +14,16 @@ repos:
|
|||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 24.2.0
|
rev: 25.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.3.2
|
rev: v0.12.10
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [ --fix, --exit-non-zero-on-fix ]
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: "v4.0.0-alpha.8"
|
rev: v3.6.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [css, javascript, html, json]
|
types_or: [css, javascript, html, json]
|
||||||
|
|||||||
+7
-20
@@ -2,26 +2,20 @@ FROM python:3.12-slim-bookworm AS builder
|
|||||||
|
|
||||||
RUN apt-get clean
|
RUN apt-get clean
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Only copy the files required to install the dependencies
|
# Only copy the files required to install the dependencies
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN touch README.md
|
RUN touch README.md
|
||||||
|
|
||||||
RUN mkdir data
|
RUN mkdir data
|
||||||
|
|
||||||
ENV POETRY_NO_INTERACTION=1 \
|
RUN uv sync --all-extras
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
|
||||||
POETRY_VIRTUALENVS_CREATE=1 \
|
|
||||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
|
||||||
|
|
||||||
ARG POETRY_INSTALL_ARGS="--only main"
|
|
||||||
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
|
|
||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
@@ -34,26 +28,19 @@ RUN apt-get update && apt-get -y upgrade && \
|
|||||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
ENV POETRY_NO_INTERACTION=1 \
|
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
|
||||||
POETRY_VIRTUALENVS_CREATE=1 \
|
|
||||||
VIRTUAL_ENV=/app/.venv \
|
|
||||||
PATH="/app/.venv/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /app/.venv .venv
|
COPY --from=builder /app/.venv .venv
|
||||||
|
|
||||||
ARG POETRY_INSTALL_ARGS="--only main"
|
RUN uv sync --all-extras
|
||||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
|
||||||
|
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
ENV LNBITS_HOST="0.0.0.0"
|
ENV LNBITS_HOST="0.0.0.0"
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||||
|
|||||||
+4
-2
@@ -7,9 +7,11 @@ RUN ls -l /usr/local/bin/boltzd
|
|||||||
RUN apt-get update && apt-get -y upgrade && \
|
RUN apt-get update && apt-get -y upgrade && \
|
||||||
apt-get install -y netcat-openbsd
|
apt-get install -y netcat-openbsd
|
||||||
|
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
# Reinstall dependencies just in case (needed for CMD usage)
|
# Reinstall dependencies just in case (needed for CMD usage)
|
||||||
ARG POETRY_INSTALL_ARGS="--only main"
|
RUN uv sync --all-extras
|
||||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
|
||||||
|
|
||||||
# LNbits + boltzd configuration
|
# LNbits + boltzd configuration
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
|
|||||||
@@ -9,34 +9,34 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
|
|||||||
test: test-unit test-wallets test-api test-regtest
|
test: test-unit test-wallets test-api test-regtest
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
poetry run ./node_modules/.bin/prettier --write .
|
uv run ./node_modules/.bin/prettier --write .
|
||||||
|
|
||||||
pyright:
|
pyright:
|
||||||
poetry run ./node_modules/.bin/pyright
|
uv run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
poetry run mypy
|
uv run mypy
|
||||||
|
|
||||||
black:
|
black:
|
||||||
poetry run black .
|
uv run black .
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
poetry run ruff check . --fix
|
uv run ruff check . --fix
|
||||||
|
|
||||||
checkruff:
|
checkruff:
|
||||||
poetry run ruff check .
|
uv run ruff check .
|
||||||
|
|
||||||
checkprettier:
|
checkprettier:
|
||||||
poetry run ./node_modules/.bin/prettier --check .
|
uv run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
checkblack:
|
checkblack:
|
||||||
poetry run black --check .
|
uv run black --check .
|
||||||
|
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
poetry run lnbits --reload
|
uv run lnbits --reload
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t lnbits/lnbits .
|
docker build -t lnbits/lnbits .
|
||||||
@@ -46,27 +46,27 @@ test-wallets:
|
|||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/wallets
|
uv run pytest tests/wallets
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/unit
|
uv run pytest tests/unit
|
||||||
|
|
||||||
test-api:
|
test-api:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/api
|
uv run pytest tests/api
|
||||||
|
|
||||||
test-regtest:
|
test-regtest:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/regtest
|
uv run pytest tests/regtest
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
LNBITS_ADMIN_UI=True \
|
LNBITS_ADMIN_UI=True \
|
||||||
@@ -74,18 +74,18 @@ test-migration:
|
|||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
LNBITS_ADMIN_UI=False \
|
LNBITS_ADMIN_UI=False \
|
||||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
poetry run python tools/conv.py
|
uv run python tools/conv.py
|
||||||
|
|
||||||
migration:
|
migration:
|
||||||
poetry run python tools/conv.py
|
uv run python tools/conv.py
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
LNBITS_ADMIN_UI=False \
|
LNBITS_ADMIN_UI=False \
|
||||||
@@ -94,9 +94,9 @@ openapi:
|
|||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5003 \
|
PORT=5003 \
|
||||||
poetry run lnbits &
|
uv run lnbits &
|
||||||
sleep 15
|
sleep 15
|
||||||
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
|
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
|
||||||
# kill -9 %1
|
# kill -9 %1
|
||||||
|
|
||||||
bak:
|
bak:
|
||||||
@@ -109,7 +109,7 @@ sass:
|
|||||||
bundle:
|
bundle:
|
||||||
npm install
|
npm install
|
||||||
npm run bundle
|
npm run bundle
|
||||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||||
|
|
||||||
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
|
||||||
@@ -126,8 +126,8 @@ checkbundle:
|
|||||||
|
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||||
poetry run pre-commit install
|
uv run pre-commit install
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
poetry run pre-commit run --all-files
|
uv run pre-commit run --all-files
|
||||||
|
|||||||
+1
-1
@@ -23,4 +23,4 @@ if ! nc -z localhost 9002; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
||||||
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ Thanks for contributing :)
|
|||||||
|
|
||||||
# Run
|
# Run
|
||||||
|
|
||||||
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
|
||||||
guide to install poetry and other dependencies.
|
guide to install uv and other dependencies.
|
||||||
|
|
||||||
Then you can start LNbits uvicorn server with:
|
Then you can start LNbits uvicorn server with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run lnbits
|
uv run lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
Or you can use the following to start uvicorn with hot reloading enabled:
|
Or you can use the following to start uvicorn with hot reloading enabled:
|
||||||
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
|
|||||||
```bash
|
```bash
|
||||||
make dev
|
make dev
|
||||||
# or
|
# or
|
||||||
poetry run lnbits --reload
|
uv run lnbits --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
You might need the following extra dependencies on clean installation of Debian:
|
You might need the following extra dependencies on clean installation of Debian:
|
||||||
@@ -50,7 +50,7 @@ make install-pre-commit-hook
|
|||||||
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry install
|
uv sync --all-extras --dev
|
||||||
npm i
|
npm i
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ make format
|
|||||||
Run mypy checks:
|
Run mypy checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run mypy
|
make mypy
|
||||||
```
|
```
|
||||||
|
|
||||||
Run everything:
|
Run everything:
|
||||||
|
|||||||
@@ -42,14 +42,10 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
|
|||||||
|
|
||||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||||
|
|
||||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
|
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`:
|
||||||
|
|
||||||
```sh
|
|
||||||
$ poetry add <package>
|
|
||||||
```
|
|
||||||
|
|
||||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||||
|
|
||||||
## SQLite to PostgreSQL migration
|
## SQLite to PostgreSQL migration
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ $ sudo nano .env
|
|||||||
Now start LNbits once in the terminal window
|
Now start LNbits once in the terminal window
|
||||||
|
|
||||||
```
|
```
|
||||||
$ poetry run lnbits
|
$ uv run lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
You can now `cat` the Super User ID:
|
You can now `cat` the Super User ID:
|
||||||
|
|||||||
+52
-26
@@ -15,23 +15,23 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
|
|||||||
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get install libfuse2
|
sudo apt-get install jq libfuse2
|
||||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
wget $(curl -s https://api.github.com/repos/arcbtc/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||||
chmod +x LNbits-latest.AppImage
|
chmod +x LNbits-latest.AppImage
|
||||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||||
```
|
```
|
||||||
|
|
||||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||||
|
|
||||||
## Option 2: Poetry (recommended for developers)
|
## Option 2: UV (recommended for developers)
|
||||||
|
|
||||||
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
|
It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
|
||||||
|
|
||||||
### Install Python 3.12
|
### Install Python 3.12
|
||||||
|
|
||||||
## Option 2 (recommended): Poetry
|
## Option 2 (recommended): UV
|
||||||
|
|
||||||
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
|
||||||
|
|
||||||
### Verify Python version
|
### Verify Python version
|
||||||
|
|
||||||
@@ -39,11 +39,19 @@ It is recommended to use the latest version of Poetry. Make sure you have Python
|
|||||||
python3 --version
|
python3 --version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install Poetry
|
### Install UV
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### (old) Install Poetry
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
||||||
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
### install LNbits
|
### install LNbits
|
||||||
@@ -51,9 +59,13 @@ curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.l
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
cd lnbits
|
cd lnbits
|
||||||
poetry env use 3.12
|
|
||||||
git checkout main
|
git checkout main
|
||||||
poetry install --only main
|
uv sync --all-extras
|
||||||
|
|
||||||
|
# or poetry
|
||||||
|
# poetry env use 3.12
|
||||||
|
# poetry install --only main
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Optional: to set funding source amongst other options via the env `nano .env`
|
# Optional: to set funding source amongst other options via the env `nano .env`
|
||||||
```
|
```
|
||||||
@@ -61,8 +73,11 @@ cp .env.example .env
|
|||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
poetry run lnbits
|
uv run lnbits
|
||||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
# To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
|
||||||
|
|
||||||
|
# or poetry
|
||||||
|
# poetry run lnbits
|
||||||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||||
```
|
```
|
||||||
@@ -71,7 +86,7 @@ poetry run lnbits
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
||||||
poetry run lnbits-cli --help
|
uv run lnbits-cli --help
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating the server
|
#### Updating the server
|
||||||
@@ -85,15 +100,18 @@ cd lnbits
|
|||||||
git pull --rebase
|
git pull --rebase
|
||||||
|
|
||||||
# Check your poetry version with
|
# Check your poetry version with
|
||||||
poetry env list
|
# poetry env list
|
||||||
# If version is less 3.12, update it by running
|
# If version is less 3.12, update it by running
|
||||||
poetry env use python3.12
|
# poetry env use python3.12
|
||||||
poetry env remove python3.9
|
# poetry env remove python3.9
|
||||||
poetry env list
|
# poetry env list
|
||||||
|
|
||||||
# Run install and start LNbits with
|
# Run install and start LNbits with
|
||||||
poetry install --only main
|
# poetry install --only main
|
||||||
poetry run lnbits
|
# poetry run lnbits
|
||||||
|
|
||||||
|
uv sync --all-extras
|
||||||
|
uv run lnbits
|
||||||
|
|
||||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||||
```
|
```
|
||||||
@@ -108,16 +126,24 @@ 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.
|
||||||
|
|
||||||
`./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 `uv run lnbits` (see previous option).
|
||||||
|
|
||||||
## Option 3: Nix
|
## Option 3: Nix
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
|
||||||
|
|
||||||
# Enable nix-command and flakes experimental features for nix:
|
# Enable nix-command and flakes experimental features for nix:
|
||||||
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
|
||||||
|
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
|
||||||
|
|
||||||
|
# Add user to Nix
|
||||||
|
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
|
||||||
|
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
|
||||||
|
|
||||||
|
# Restart daemon so changes apply
|
||||||
|
sudo systemctl restart nix-daemon
|
||||||
|
|
||||||
# Add cachix for cached binaries
|
# Add cachix for cached binaries
|
||||||
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
@@ -142,7 +168,7 @@ but you can also set the env variables or pass command line arguments:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||||
|
|
||||||
# Once you have created a user, you can set as the super_user
|
# Once you have created a user, you can set as the super_user
|
||||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
@@ -332,7 +358,7 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
|||||||
|
|
||||||
# START LNbits
|
# START LNbits
|
||||||
# STOP LNbits
|
# STOP LNbits
|
||||||
poetry run python tools/conv.py
|
uv run python tools/conv.py
|
||||||
# or
|
# or
|
||||||
make migration
|
make migration
|
||||||
```
|
```
|
||||||
@@ -357,8 +383,8 @@ Description=LNbits
|
|||||||
[Service]
|
[Service]
|
||||||
# replace with the absolute path of your lnbits installation
|
# replace with the absolute path of your lnbits installation
|
||||||
WorkingDirectory=/home/lnbits/lnbits
|
WorkingDirectory=/home/lnbits/lnbits
|
||||||
# same here. run `which poetry` if you can't find the poetry binary
|
# same here. run `which uv` if you can't find the poetry binary
|
||||||
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
ExecStart=/home/lnbits/.local/bin/uv run lnbits
|
||||||
# replace with the user that you're running lnbits on
|
# replace with the user that you're running lnbits on
|
||||||
User=lnbits
|
User=lnbits
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
|||||||
|
|
||||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||||
|
|
||||||
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
|
To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
|
||||||
|
|
||||||
### LNbits
|
### LNbits
|
||||||
|
|
||||||
|
|||||||
Generated
+82
-67
@@ -1,15 +1,39 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"build-system-pkgs": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"pyproject-nix": [
|
||||||
|
"pyproject-nix"
|
||||||
|
],
|
||||||
|
"uv2nix": "uv2nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1755484659,
|
||||||
|
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "build-system-pkgs",
|
||||||
|
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "build-system-pkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1710146030,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -18,71 +42,49 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nix-github-actions": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"poetry2nix",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1703863825,
|
|
||||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nix-github-actions",
|
|
||||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nix-github-actions",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1735563628,
|
"lastModified": 1751274312,
|
||||||
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
|
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
|
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-24.05",
|
"ref": "nixos-24.11",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"poetry2nix": {
|
"pyproject-nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nix-github-actions": "nix-github-actions",
|
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
]
|
||||||
"systems": "systems_2",
|
|
||||||
"treefmt-nix": "treefmt-nix"
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1724134185,
|
"lastModified": 1754923840,
|
||||||
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
|
||||||
"owner": "nix-community",
|
"owner": "pyproject-nix",
|
||||||
"repo": "poetry2nix",
|
"repo": "pyproject.nix",
|
||||||
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"owner": "pyproject-nix",
|
||||||
"repo": "poetry2nix",
|
"repo": "pyproject.nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"build-system-pkgs": "build-system-pkgs",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"poetry2nix": "poetry2nix"
|
"pyproject-nix": "pyproject-nix",
|
||||||
|
"uv2nix": "uv2nix_2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
@@ -100,38 +102,51 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems_2": {
|
"uv2nix": {
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"id": "systems",
|
|
||||||
"type": "indirect"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"treefmt-nix": {
|
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"poetry2nix",
|
"build-system-pkgs",
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"pyproject-nix": [
|
||||||
|
"build-system-pkgs",
|
||||||
|
"pyproject-nix"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1719749022,
|
"lastModified": 1755210905,
|
||||||
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
|
||||||
"owner": "numtide",
|
"owner": "pyproject-nix",
|
||||||
"repo": "treefmt-nix",
|
"repo": "uv2nix",
|
||||||
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "pyproject-nix",
|
||||||
"repo": "treefmt-nix",
|
"repo": "uv2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uv2nix_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"pyproject-nix": [
|
||||||
|
"pyproject-nix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1755485731,
|
||||||
|
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "uv2nix",
|
||||||
|
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "uv2nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,150 @@
|
|||||||
{
|
{
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||||
poetry2nix = {
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
url = "github:nix-community/poetry2nix";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
|
||||||
};
|
uv2nix.url = "github:pyproject-nix/uv2nix";
|
||||||
|
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
|
||||||
|
|
||||||
|
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
|
||||||
|
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
|
||||||
};
|
};
|
||||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
|
||||||
let
|
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
|
||||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
|
||||||
forSystems = systems: f:
|
(system:
|
||||||
nixpkgs.lib.genAttrs systems
|
let
|
||||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
|
pkgs = import nixpkgs { inherit system; };
|
||||||
forAllSystems = forSystems supportedSystems;
|
lib = pkgs.lib;
|
||||||
projectName = "lnbits";
|
|
||||||
in
|
python = pkgs.python312;
|
||||||
{
|
|
||||||
overlays = {
|
# Read uv.lock / pyproject via uv2nix
|
||||||
default = final: prev: {
|
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
|
||||||
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
|
||||||
};
|
# Prefer wheels when available
|
||||||
};
|
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
|
||||||
packages = forAllSystems (system: pkgs: {
|
|
||||||
default = self.packages.${system}.${projectName};
|
# Helper for extending lists safely (works if a is null)
|
||||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b);
|
||||||
projectDir = ./.;
|
|
||||||
meta.rev = self.dirtyRev or self.rev;
|
# Extra build inputs for troublesome sdists
|
||||||
meta.mainProgram = projectName;
|
myOverrides = (final: prev: {
|
||||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
# embit needs setuptools at build time
|
||||||
coincurve = prev.coincurve.override { preferWheel = true; };
|
embit = prev.embit.overrideAttrs (old: {
|
||||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
||||||
ruff = prev.ruff.override { preferWheel = true; };
|
|
||||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
|
||||||
tlv8 = prev.tlv8.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
|
||||||
prev.setuptools
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
pynostr = prev.pynostr.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
# http-ece (pywebpush dep) needs setuptools
|
||||||
prev.setuptools-scm
|
"http-ece" = prev."http-ece".overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
||||||
|
});
|
||||||
|
|
||||||
|
# pyqrcode needs setuptools
|
||||||
|
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
||||||
|
});
|
||||||
|
|
||||||
|
# tlv8 needs setuptools
|
||||||
|
tlv8 = prev.tlv8.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
||||||
|
});
|
||||||
|
|
||||||
|
# secp256k1 Python binding:
|
||||||
|
# - setuptools, pkg-config
|
||||||
|
# - cffi + pycparser
|
||||||
|
# - system libsecp256k1 for headers/libs
|
||||||
|
secp256k1 = prev.secp256k1.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
|
||||||
|
prev.setuptools
|
||||||
|
pkgs.pkg-config
|
||||||
|
prev.cffi
|
||||||
|
prev.pycparser
|
||||||
];
|
];
|
||||||
|
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
|
||||||
|
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
|
||||||
|
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
|
||||||
|
});
|
||||||
|
|
||||||
|
# pynostr uses setuptools-scm for versioning
|
||||||
|
pynostr = prev.pynostr.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
|
||||||
});
|
# Compose Python package set honoring uv.lock
|
||||||
nixosModules = {
|
pythonSet =
|
||||||
default = { pkgs, lib, config, ... }: {
|
(pkgs.callPackage pyproject-nix.build.packages { inherit python; })
|
||||||
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
|
.overrideScope (lib.composeManyExtensions [
|
||||||
nixpkgs.overlays = [ self.overlays.default ];
|
build-system-pkgs.overlays.default
|
||||||
};
|
uvLockedOverlay
|
||||||
};
|
myOverrides
|
||||||
checks = forAllSystems (system: pkgs:
|
]);
|
||||||
let
|
|
||||||
vmTests = import ./nix/tests {
|
projectName = "lnbits";
|
||||||
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
|
|
||||||
inherit inputs pkgs;
|
# Build a venv from the locked spec (this installs the resolved wheels)
|
||||||
|
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
|
||||||
|
|
||||||
|
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
|
||||||
|
lnbitsApp = pkgs.writeShellApplication {
|
||||||
|
name = "lnbits";
|
||||||
|
text = ''
|
||||||
|
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
|
||||||
|
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
|
||||||
|
exec ${runtimeVenv}/bin/lnbits "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
lnbitsCliApp = pkgs.writeShellApplication {
|
||||||
|
name = "lnbits-cli";
|
||||||
|
text = ''
|
||||||
|
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
|
||||||
|
exec ${runtimeVenv}/bin/lnbits-cli "$@"
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
|
|
||||||
//
|
|
||||||
{
|
{
|
||||||
# Other checks here...
|
# nix build → produces the venv in ./result
|
||||||
}
|
packages.default = runtimeVenv;
|
||||||
);
|
packages.${projectName} = runtimeVenv;
|
||||||
};
|
|
||||||
|
# nix run . → launches via wrapper that imports from source tree
|
||||||
|
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
|
||||||
|
apps.${projectName} = self.apps.${system}.default;
|
||||||
|
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
|
||||||
|
|
||||||
|
# dev shell with locked deps + tools
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
runtimeVenv
|
||||||
|
pkgs.uv
|
||||||
|
pkgs.ruff
|
||||||
|
pkgs.black
|
||||||
|
pkgs.mypy
|
||||||
|
pkgs.pre-commit
|
||||||
|
pkgs.openapi-generator-cli
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
|
||||||
|
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
|
||||||
|
};
|
||||||
|
|
||||||
|
# System-specific nixos modules to avoid circular dependency
|
||||||
|
nixosModules.default = { pkgs, lib, config, ... }: {
|
||||||
|
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
||||||
|
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
||||||
|
};
|
||||||
|
|
||||||
|
checks = { };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ if [ ! -d lnbits/data ]; then
|
|||||||
# Install Python 3.10 and distutils non-interactively
|
# Install Python 3.10 and distutils non-interactively
|
||||||
sudo apt install -y python3.10 python3.10-distutils
|
sudo apt install -y python3.10 python3.10-distutils
|
||||||
|
|
||||||
# Install Poetry
|
# Install UV
|
||||||
curl -sSL https://install.python-poetry.org | python3.10 -
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
# Add Poetry to PATH for the current session
|
|
||||||
export PATH="/home/$USER/.local/bin:$PATH"
|
export PATH="/home/$USER/.local/bin:$PATH"
|
||||||
|
|
||||||
if [ ! -d lnbits/wallets ]; then
|
if [ ! -d lnbits/wallets ]; then
|
||||||
@@ -42,13 +41,13 @@ 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
|
||||||
|
|
||||||
# Install the dependencies using Poetry
|
# Install the dependencies using UV
|
||||||
poetry env use python3.9
|
uv sync --all-extras
|
||||||
poetry install --only main
|
|
||||||
|
|
||||||
# Set environment variables for LNbits
|
# Set environment variables for LNbits
|
||||||
export LNBITS_ADMIN_UI=true
|
export LNBITS_ADMIN_UI=true
|
||||||
export HOST=0.0.0.0
|
export HOST=0.0.0.0
|
||||||
|
|
||||||
# Run LNbits
|
# Run LNbits
|
||||||
poetry run lnbits
|
uv run lnbits
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -280,7 +280,7 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
async def build_all_installed_extensions_list( # noqa: C901
|
async def build_all_installed_extensions_list( # noqa: C901
|
||||||
include_deactivated: Optional[bool] = True,
|
include_deactivated: bool | None = 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
|
||||||
|
|||||||
+32
-33
@@ -5,7 +5,6 @@ import time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -96,7 +95,7 @@ def decrypt():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_super_user() -> Optional[str]:
|
def get_super_user() -> str | None:
|
||||||
"""Get the superuser"""
|
"""Get the superuser"""
|
||||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||||
if not superuser_file.exists() or not superuser_file.is_file():
|
if not superuser_file.exists() or not superuser_file.is_file():
|
||||||
@@ -155,7 +154,7 @@ async def db_versions():
|
|||||||
@db.command("cleanup-wallets")
|
@db.command("cleanup-wallets")
|
||||||
@click.argument("days", type=int, required=False)
|
@click.argument("days", type=int, required=False)
|
||||||
@coro
|
@coro
|
||||||
async def database_cleanup_wallets(days: Optional[int] = None):
|
async def database_cleanup_wallets(days: int | None = None):
|
||||||
"""Delete all wallets that never had any transaction"""
|
"""Delete all wallets that never had any transaction"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
delta = days or settings.cleanup_wallets_days
|
delta = days or settings.cleanup_wallets_days
|
||||||
@@ -212,10 +211,10 @@ async def database_revert_payment(checking_id: str):
|
|||||||
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
||||||
@coro
|
@coro
|
||||||
async def check_invalid_payments(
|
async def check_invalid_payments(
|
||||||
days: Optional[int] = None,
|
days: int | None = None,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
wallet: Optional[str] = None,
|
wallet: str | None = None,
|
||||||
verbose: Optional[bool] = False,
|
verbose: bool | None = False,
|
||||||
):
|
):
|
||||||
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
||||||
await check_admin_settings()
|
await check_admin_settings()
|
||||||
@@ -303,7 +302,7 @@ async def create_user(username: str, password: str):
|
|||||||
@users.command("cleanup-accounts")
|
@users.command("cleanup-accounts")
|
||||||
@click.argument("days", type=int, required=False)
|
@click.argument("days", type=int, required=False)
|
||||||
@coro
|
@coro
|
||||||
async def database_cleanup_accounts(days: Optional[int] = None):
|
async def database_cleanup_accounts(days: int | None = None):
|
||||||
"""Delete all accounts that have no wallets"""
|
"""Delete all accounts that have no wallets"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
delta = days or settings.cleanup_wallets_days
|
delta = days or settings.cleanup_wallets_days
|
||||||
@@ -353,12 +352,12 @@ async def extensions_list():
|
|||||||
)
|
)
|
||||||
@coro
|
@coro
|
||||||
async def extensions_update( # noqa: C901
|
async def extensions_update( # noqa: C901
|
||||||
extension: Optional[str] = None,
|
extension: str | None = None,
|
||||||
all_extensions: Optional[bool] = False,
|
all_extensions: bool | None = False,
|
||||||
repo_index: Optional[str] = None,
|
repo_index: str | None = None,
|
||||||
source_repo: Optional[str] = None,
|
source_repo: str | None = None,
|
||||||
url: Optional[str] = None,
|
url: str | None = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: str | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update extension to the latest version.
|
Update extension to the latest version.
|
||||||
@@ -443,10 +442,10 @@ async def extensions_update( # noqa: C901
|
|||||||
@coro
|
@coro
|
||||||
async def extensions_install(
|
async def extensions_install(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: Optional[str] = None,
|
repo_index: str | None = None,
|
||||||
source_repo: Optional[str] = None,
|
source_repo: str | None = None,
|
||||||
url: Optional[str] = None,
|
url: str | None = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: str | None = None,
|
||||||
):
|
):
|
||||||
"""Install a extension"""
|
"""Install a extension"""
|
||||||
click.echo(f"Installing {extension}... {repo_index}")
|
click.echo(f"Installing {extension}... {repo_index}")
|
||||||
@@ -473,7 +472,7 @@ async def extensions_install(
|
|||||||
)
|
)
|
||||||
@coro
|
@coro
|
||||||
async def extensions_uninstall(
|
async def extensions_uninstall(
|
||||||
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
|
extension: str, url: str | None = None, admin_user: str | None = None
|
||||||
):
|
):
|
||||||
"""Uninstall a extension"""
|
"""Uninstall a extension"""
|
||||||
click.echo(f"Uninstalling '{extension}'...")
|
click.echo(f"Uninstalling '{extension}'...")
|
||||||
@@ -562,10 +561,10 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
async def install_extension(
|
async def install_extension(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: Optional[str] = None,
|
repo_index: str | None = None,
|
||||||
source_repo: Optional[str] = None,
|
source_repo: str | None = None,
|
||||||
url: Optional[str] = None,
|
url: str | None = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: str | None = 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)
|
||||||
@@ -591,10 +590,10 @@ async def install_extension(
|
|||||||
|
|
||||||
async def update_extension(
|
async def update_extension(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: Optional[str] = None,
|
repo_index: str | None = None,
|
||||||
source_repo: Optional[str] = None,
|
source_repo: str | None = None,
|
||||||
url: Optional[str] = None,
|
url: str | None = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: str | None = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
click.echo(f"Updating '{extension}' extension.")
|
click.echo(f"Updating '{extension}' extension.")
|
||||||
@@ -644,9 +643,9 @@ async def update_extension(
|
|||||||
|
|
||||||
async def _select_release(
|
async def _select_release(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: Optional[str] = None,
|
repo_index: str | None = None,
|
||||||
source_repo: Optional[str] = None,
|
source_repo: str | None = None,
|
||||||
) -> Optional[ExtensionRelease]:
|
) -> ExtensionRelease | None:
|
||||||
all_releases = await InstallableExtension.get_extension_releases(extension)
|
all_releases = await InstallableExtension.get_extension_releases(extension)
|
||||||
if len(all_releases) == 0:
|
if len(all_releases) == 0:
|
||||||
click.echo(f"No repository found for extension '{extension}'.")
|
click.echo(f"No repository found for extension '{extension}'.")
|
||||||
@@ -706,7 +705,7 @@ def _get_latest_release_per_repo(all_releases):
|
|||||||
|
|
||||||
|
|
||||||
async def _call_install_extension(
|
async def _call_install_extension(
|
||||||
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
|
data: CreateExtension, url: str | None, user_id: str | None = None
|
||||||
):
|
):
|
||||||
if url:
|
if url:
|
||||||
user_id = user_id or get_super_user()
|
user_id = user_id or get_super_user()
|
||||||
@@ -720,7 +719,7 @@ async def _call_install_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def _call_uninstall_extension(
|
async def _call_uninstall_extension(
|
||||||
extension: str, url: Optional[str], user_id: Optional[str] = None
|
extension: str, url: str | None, user_id: str | None = None
|
||||||
):
|
):
|
||||||
if url:
|
if url:
|
||||||
user_id = user_id or get_super_user()
|
user_id = user_id or get_super_user()
|
||||||
@@ -756,7 +755,7 @@ async def _can_run_operation(url) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _is_lnbits_started(url: Optional[str]):
|
async def _is_lnbits_started(url: str | None):
|
||||||
try:
|
try:
|
||||||
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .views.audit_api import audit_router
|
|||||||
from .views.auth_api import auth_router
|
from .views.auth_api import auth_router
|
||||||
from .views.callback_api import callback_router
|
from .views.callback_api import callback_router
|
||||||
from .views.extension_api import extension_router
|
from .views.extension_api import extension_router
|
||||||
|
from .views.extensions_builder_api import extension_builder_router
|
||||||
from .views.fiat_api import fiat_router
|
from .views.fiat_api import fiat_router
|
||||||
|
|
||||||
# this compat is needed for usermanager extension
|
# this compat is needed for usermanager extension
|
||||||
@@ -31,6 +32,7 @@ def init_core_routers(app: FastAPI):
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(node_router)
|
app.include_router(node_router)
|
||||||
app.include_router(extension_router)
|
app.include_router(extension_router)
|
||||||
|
app.include_router(extension_builder_router)
|
||||||
app.include_router(super_node_router)
|
app.include_router(super_node_router)
|
||||||
app.include_router(public_node_router)
|
app.include_router(public_node_router)
|
||||||
app.include_router(payment_router)
|
app.include_router(payment_router)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import AuditEntry, AuditFilters
|
from lnbits.core.models import AuditEntry, AuditFilters
|
||||||
from lnbits.core.models.audit import AuditCountStat
|
from lnbits.core.models.audit import AuditCountStat
|
||||||
@@ -8,14 +6,14 @@ from lnbits.db import Connection, Filters, Page
|
|||||||
|
|
||||||
async def create_audit_entry(
|
async def create_audit_entry(
|
||||||
entry: AuditEntry,
|
entry: AuditEntry,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("audit", entry)
|
await (conn or db).insert("audit", entry)
|
||||||
|
|
||||||
|
|
||||||
async def get_audit_entries(
|
async def get_audit_entries(
|
||||||
filters: Optional[Filters[AuditFilters]] = None,
|
filters: Filters[AuditFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Page[AuditEntry]:
|
) -> Page[AuditEntry]:
|
||||||
return await (conn or db).fetch_page(
|
return await (conn or db).fetch_page(
|
||||||
"SELECT * from audit",
|
"SELECT * from audit",
|
||||||
@@ -27,7 +25,7 @@ async def get_audit_entries(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_expired_audit_entries(
|
async def delete_expired_audit_entries(
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
):
|
):
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||||
@@ -40,8 +38,8 @@ async def delete_expired_audit_entries(
|
|||||||
|
|
||||||
async def get_count_stats(
|
async def get_count_stats(
|
||||||
field: str,
|
field: str,
|
||||||
filters: Optional[Filters[AuditFilters]] = None,
|
filters: Filters[AuditFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> list[AuditCountStat]:
|
) -> list[AuditCountStat]:
|
||||||
if field not in ["request_method", "component", "response_code"]:
|
if field not in ["request_method", "component", "response_code"]:
|
||||||
return []
|
return []
|
||||||
@@ -67,8 +65,8 @@ async def get_count_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_long_duration_stats(
|
async def get_long_duration_stats(
|
||||||
filters: Optional[Filters[AuditFilters]] = None,
|
filters: Filters[AuditFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> list[AuditCountStat]:
|
) -> list[AuditCountStat]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
|
|
||||||
@@ -7,8 +5,8 @@ from ..models import DbVersion
|
|||||||
|
|
||||||
|
|
||||||
async def get_db_version(
|
async def get_db_version(
|
||||||
ext_id: str, conn: Optional[Connection] = None
|
ext_id: str, conn: Connection | None = None
|
||||||
) -> Optional[DbVersion]:
|
) -> DbVersion | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM dbversions WHERE db = :ext_id",
|
"SELECT * FROM dbversions WHERE db = :ext_id",
|
||||||
{"ext_id": ext_id},
|
{"ext_id": ext_id},
|
||||||
@@ -16,7 +14,7 @@ async def get_db_version(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
|
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
|
||||||
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ async def update_migration_version(conn, db_name, version):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM dbversions WHERE db = :ext
|
DELETE FROM dbversions WHERE db = :ext
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models.extensions import (
|
from lnbits.core.models.extensions import (
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
@@ -10,20 +8,20 @@ from lnbits.db import Connection, Database
|
|||||||
|
|
||||||
async def create_installed_extension(
|
async def create_installed_extension(
|
||||||
ext: InstallableExtension,
|
ext: InstallableExtension,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("installed_extensions", ext)
|
await (conn or db).insert("installed_extensions", ext)
|
||||||
|
|
||||||
|
|
||||||
async def update_installed_extension(
|
async def update_installed_extension(
|
||||||
ext: InstallableExtension,
|
ext: InstallableExtension,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).update("installed_extensions", ext)
|
await (conn or db).update("installed_extensions", ext)
|
||||||
|
|
||||||
|
|
||||||
async def update_installed_extension_state(
|
async def update_installed_extension_state(
|
||||||
*, ext_id: str, active: bool, conn: Optional[Connection] = None
|
*, ext_id: str, active: bool, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
@@ -34,7 +32,7 @@ async def update_installed_extension_state(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_installed_extension(
|
async def delete_installed_extension(
|
||||||
*, ext_id: str, conn: Optional[Connection] = None
|
*, ext_id: str, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
@@ -44,7 +42,7 @@ async def delete_installed_extension(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
|
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
|
||||||
row: dict = await (conn or db).fetchone(
|
row: dict = await (conn or db).fetchone(
|
||||||
"SELECT * FROM dbversions WHERE db = :id",
|
"SELECT * FROM dbversions WHERE db = :id",
|
||||||
{"id": ext_id},
|
{"id": ext_id},
|
||||||
@@ -65,8 +63,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
|
|||||||
|
|
||||||
|
|
||||||
async def get_installed_extension(
|
async def get_installed_extension(
|
||||||
ext_id: str, conn: Optional[Connection] = None
|
ext_id: str, conn: Connection | None = None
|
||||||
) -> Optional[InstallableExtension]:
|
) -> InstallableExtension | None:
|
||||||
extension = await (conn or db).fetchone(
|
extension = await (conn or db).fetchone(
|
||||||
"SELECT * FROM installed_extensions WHERE id = :id",
|
"SELECT * FROM installed_extensions WHERE id = :id",
|
||||||
{"id": ext_id},
|
{"id": ext_id},
|
||||||
@@ -76,8 +74,8 @@ async def get_installed_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def get_installed_extensions(
|
async def get_installed_extensions(
|
||||||
active: Optional[bool] = None,
|
active: bool | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> list[InstallableExtension]:
|
) -> list[InstallableExtension]:
|
||||||
query = "SELECT * FROM installed_extensions"
|
query = "SELECT * FROM installed_extensions"
|
||||||
if active is not None:
|
if active is not None:
|
||||||
@@ -93,8 +91,8 @@ async def get_installed_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_extension(
|
async def get_user_extension(
|
||||||
user_id: str, extension: str, conn: Optional[Connection] = None
|
user_id: str, extension: str, conn: Connection | None = None
|
||||||
) -> Optional[UserExtension]:
|
) -> UserExtension | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM extensions
|
SELECT * FROM extensions
|
||||||
@@ -106,7 +104,7 @@ async def get_user_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_extensions(
|
async def get_user_extensions(
|
||||||
user_id: str, conn: Optional[Connection] = None
|
user_id: str, conn: Connection | None = None
|
||||||
) -> list[UserExtension]:
|
) -> list[UserExtension]:
|
||||||
return await (conn or db).fetchall(
|
return await (conn or db).fetchall(
|
||||||
"""SELECT * FROM extensions WHERE "user" = :user""",
|
"""SELECT * FROM extensions WHERE "user" = :user""",
|
||||||
@@ -116,20 +114,20 @@ async def get_user_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_extension(
|
async def create_user_extension(
|
||||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
user_extension: UserExtension, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("extensions", user_extension)
|
await (conn or db).insert("extensions", user_extension)
|
||||||
|
|
||||||
|
|
||||||
async def update_user_extension(
|
async def update_user_extension(
|
||||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
user_extension: UserExtension, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
where = """WHERE extension = :extension AND "user" = :user"""
|
where = """WHERE extension = :extension AND "user" = :user"""
|
||||||
await (conn or db).update("extensions", user_extension, where)
|
await (conn or db).update("extensions", user_extension, where)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_active_extensions_ids(
|
async def get_user_active_extensions_ids(
|
||||||
user_id: str, conn: Optional[Connection] = None
|
user_id: str, conn: Connection | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
exts = await (conn or db).fetchall(
|
exts = await (conn or db).fetchall(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from time import time
|
from time import time
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
@@ -23,7 +23,7 @@ def update_payment_extra():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
||||||
{"checking_id": checking_id},
|
{"checking_id": checking_id},
|
||||||
@@ -33,10 +33,10 @@ async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Pa
|
|||||||
|
|
||||||
async def get_standalone_payment(
|
async def get_standalone_payment(
|
||||||
checking_id_or_hash: str,
|
checking_id_or_hash: str,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
incoming: Optional[bool] = False,
|
incoming: bool | None = False,
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: str | None = None,
|
||||||
) -> Optional[Payment]:
|
) -> Payment | None:
|
||||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||||
values = {
|
values = {
|
||||||
"wallet_id": wallet_id,
|
"wallet_id": wallet_id,
|
||||||
@@ -64,8 +64,8 @@ async def get_standalone_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet_payment(
|
async def get_wallet_payment(
|
||||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||||
) -> Optional[Payment]:
|
) -> Payment | None:
|
||||||
payment = await (conn or db).fetchone(
|
payment = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -102,17 +102,17 @@ async def get_latest_payments_by_extension(
|
|||||||
|
|
||||||
async def get_payments_paginated( # noqa: C901
|
async def get_payments_paginated( # noqa: C901
|
||||||
*,
|
*,
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: str | None = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
complete: bool = False,
|
complete: bool = False,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
failed: bool = False,
|
failed: bool = False,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
incoming: bool = False,
|
incoming: bool = False,
|
||||||
since: Optional[int] = None,
|
since: int | None = None,
|
||||||
exclude_uncheckable: bool = False,
|
exclude_uncheckable: bool = False,
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Filters[PaymentFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Page[Payment]:
|
) -> Page[Payment]:
|
||||||
"""
|
"""
|
||||||
Filters payments to be returned by:
|
Filters payments to be returned by:
|
||||||
@@ -176,17 +176,17 @@ async def get_payments_paginated( # noqa: C901
|
|||||||
|
|
||||||
async def get_payments(
|
async def get_payments(
|
||||||
*,
|
*,
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: str | None = None,
|
||||||
complete: bool = False,
|
complete: bool = False,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
incoming: bool = False,
|
incoming: bool = False,
|
||||||
since: Optional[int] = None,
|
since: int | None = None,
|
||||||
exclude_uncheckable: bool = False,
|
exclude_uncheckable: bool = False,
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Filters[PaymentFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
offset: Optional[int] = None,
|
offset: int | None = None,
|
||||||
) -> list[Payment]:
|
) -> list[Payment]:
|
||||||
"""
|
"""
|
||||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||||
@@ -230,7 +230,7 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
|||||||
|
|
||||||
|
|
||||||
async def delete_expired_invoices(
|
async def delete_expired_invoices(
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# first we delete all invoices older than one month
|
# first we delete all invoices older than one month
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ async def create_payment(
|
|||||||
checking_id: str,
|
checking_id: str,
|
||||||
data: CreatePayment,
|
data: CreatePayment,
|
||||||
status: PaymentState = PaymentState.PENDING,
|
status: PaymentState = PaymentState.PENDING,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# we don't allow the creation of the same invoice twice
|
# we don't allow the creation of the same invoice twice
|
||||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||||
@@ -290,7 +290,7 @@ async def create_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def update_payment_checking_id(
|
async def update_payment_checking_id(
|
||||||
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
|
checking_id: str, new_checking_id: str, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
||||||
@@ -300,8 +300,8 @@ async def update_payment_checking_id(
|
|||||||
|
|
||||||
async def update_payment(
|
async def update_payment(
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
new_checking_id: Optional[str] = None,
|
new_checking_id: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).update(
|
await (conn or db).update(
|
||||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||||
@@ -311,9 +311,9 @@ async def update_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def get_payments_history(
|
async def get_payments_history(
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: str | None = None,
|
||||||
group: DateTrunc = "day",
|
group: DateTrunc = "day",
|
||||||
filters: Optional[Filters] = None,
|
filters: Filters | None = None,
|
||||||
) -> list[PaymentHistoryPoint]:
|
) -> list[PaymentHistoryPoint]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
@@ -376,9 +376,9 @@ async def get_payments_history(
|
|||||||
|
|
||||||
async def get_payment_count_stats(
|
async def get_payment_count_stats(
|
||||||
field: PaymentCountField,
|
field: PaymentCountField,
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Filters[PaymentFilters] | None = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> list[PaymentCountStat]:
|
) -> list[PaymentCountStat]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
@@ -409,9 +409,9 @@ async def get_payment_count_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_daily_stats(
|
async def get_daily_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Filters[PaymentFilters] | None = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
@@ -459,9 +459,9 @@ async def get_daily_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallets_stats(
|
async def get_wallets_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Filters[PaymentFilters] | None = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> list[PaymentWalletStats]:
|
) -> list[PaymentWalletStats]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
@@ -508,7 +508,7 @@ async def get_wallets_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_wallet_payment(
|
async def delete_wallet_payment(
|
||||||
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
checking_id: str, wallet_id: str, conn: Connection | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
||||||
@@ -517,8 +517,8 @@ async def delete_wallet_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def check_internal(
|
async def check_internal(
|
||||||
payment_hash: str, conn: Optional[Connection] = None
|
payment_hash: str, conn: Connection | None = None
|
||||||
) -> Optional[Payment]:
|
) -> Payment | None:
|
||||||
"""
|
"""
|
||||||
Returns the checking_id of the internal payment if it exists,
|
Returns the checking_id of the internal payment if it exists,
|
||||||
otherwise None
|
otherwise None
|
||||||
@@ -534,7 +534,7 @@ async def check_internal(
|
|||||||
|
|
||||||
|
|
||||||
async def is_internal_status_success(
|
async def is_internal_status_success(
|
||||||
payment_hash: str, conn: Optional[Connection] = None
|
payment_hash: str, conn: Connection | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns True if the internal payment was found and is successful,
|
Returns True if the internal payment was found and is successful,
|
||||||
@@ -563,7 +563,7 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _only_user_wallets_statement(
|
async def _only_user_wallets_statement(
|
||||||
user_id: str, conn: Optional[Connection] = None
|
user_id: str, conn: Connection | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
||||||
"no-wallets-for-user"
|
"no-wallets-for-user"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from lnbits.settings import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_super_settings() -> Optional[SuperSettings]:
|
async def get_super_settings() -> SuperSettings | None:
|
||||||
data = await get_settings_by_tag("core")
|
data = await get_settings_by_tag("core")
|
||||||
if data:
|
if data:
|
||||||
super_user = await get_settings_field("super_user")
|
super_user = await get_settings_field("super_user")
|
||||||
@@ -24,7 +24,7 @@ async def get_super_settings() -> Optional[SuperSettings]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
|
||||||
sets = await get_super_settings()
|
sets = await get_super_settings()
|
||||||
if not sets:
|
if not sets:
|
||||||
return None
|
return None
|
||||||
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
|||||||
|
|
||||||
|
|
||||||
async def update_admin_settings(
|
async def update_admin_settings(
|
||||||
data: EditableSettings, tag: Optional[str] = "core"
|
data: EditableSettings, tag: str | None = "core"
|
||||||
) -> None:
|
) -> None:
|
||||||
editable_settings = await get_settings_by_tag("core") or {}
|
editable_settings = await get_settings_by_tag("core") or {}
|
||||||
editable_settings.update(data.dict(exclude_unset=True))
|
editable_settings.update(data.dict(exclude_unset=True))
|
||||||
@@ -61,7 +61,7 @@ async def update_super_user(super_user: str) -> SuperSettings:
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"DELETE FROM system_settings WHERE tag = :tag",
|
"DELETE FROM system_settings WHERE tag = :tag",
|
||||||
{"tag": tag},
|
{"tag": tag},
|
||||||
@@ -93,8 +93,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
|
|||||||
|
|
||||||
|
|
||||||
async def get_settings_field(
|
async def get_settings_field(
|
||||||
id_: str, tag: Optional[str] = "core"
|
id_: str, tag: str | None = "core"
|
||||||
) -> Optional[SettingsField]:
|
) -> SettingsField | None:
|
||||||
|
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
@@ -108,9 +108,7 @@ async def get_settings_field(
|
|||||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||||
|
|
||||||
|
|
||||||
async def set_settings_field(
|
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
||||||
id_: str, value: Optional[Any], tag: Optional[str] = "core"
|
|
||||||
):
|
|
||||||
value = json.dumps(value) if value is not None else None
|
value = json.dumps(value) if value is not None else None
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -122,7 +120,7 @@ async def set_settings_field(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
|
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
@@ -19,7 +17,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
|||||||
return await get_tinyurl(tinyurl_id)
|
return await get_tinyurl(tinyurl_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
||||||
{"tinyurl": tinyurl_id},
|
{"tinyurl": tinyurl_id},
|
||||||
|
|||||||
+20
-22
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||||
@@ -18,8 +18,8 @@ from ..models import (
|
|||||||
|
|
||||||
|
|
||||||
async def create_account(
|
async def create_account(
|
||||||
account: Optional[Account] = None,
|
account: Account | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
if account:
|
if account:
|
||||||
account.validate_fields()
|
account.validate_fields()
|
||||||
@@ -36,7 +36,7 @@ async def update_account(account: Account) -> Account:
|
|||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
|
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE from accounts WHERE id = :user",
|
"DELETE from accounts WHERE id = :user",
|
||||||
{"user": user_id},
|
{"user": user_id},
|
||||||
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Optional[Connection] = None) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def get_accounts(
|
async def get_accounts(
|
||||||
filters: Optional[Filters[AccountFilters]] = None,
|
filters: Filters[AccountFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Page[AccountOverview]:
|
) -> Page[AccountOverview]:
|
||||||
where_clauses = []
|
where_clauses = []
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
@@ -92,9 +92,7 @@ async def get_accounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account(
|
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
|
||||||
user_id: str, conn: Optional[Connection] = None
|
|
||||||
) -> Optional[Account]:
|
|
||||||
if len(user_id) == 0:
|
if len(user_id) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
@@ -106,7 +104,7 @@ async def get_account(
|
|||||||
|
|
||||||
async def delete_accounts_no_wallets(
|
async def delete_accounts_no_wallets(
|
||||||
time_delta: int,
|
time_delta: int,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
delta = int(time()) - time_delta
|
delta = int(time()) - time_delta
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
@@ -125,8 +123,8 @@ async def delete_accounts_no_wallets(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_by_username(
|
async def get_account_by_username(
|
||||||
username: str, conn: Optional[Connection] = None
|
username: str, conn: Connection | None = None
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
if len(username) == 0:
|
if len(username) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
@@ -137,8 +135,8 @@ async def get_account_by_username(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_by_pubkey(
|
async def get_account_by_pubkey(
|
||||||
pubkey: str, conn: Optional[Connection] = None
|
pubkey: str, conn: Connection | None = None
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||||
{"pubkey": pubkey.lower()},
|
{"pubkey": pubkey.lower()},
|
||||||
@@ -147,8 +145,8 @@ async def get_account_by_pubkey(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_by_email(
|
async def get_account_by_email(
|
||||||
email: str, conn: Optional[Connection] = None
|
email: str, conn: Connection | None = None
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
@@ -159,8 +157,8 @@ async def get_account_by_email(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_by_username_or_email(
|
async def get_account_by_username_or_email(
|
||||||
username_or_email: str, conn: Optional[Connection] = None
|
username_or_email: str, conn: Connection | None = None
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
@@ -171,7 +169,7 @@ async def get_account_by_username_or_email(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
||||||
account = await get_account(user_id, conn)
|
account = await get_account(user_id, conn)
|
||||||
if not account:
|
if not account:
|
||||||
return None
|
return None
|
||||||
@@ -179,8 +177,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_from_account(
|
async def get_user_from_account(
|
||||||
account: Account, conn: Optional[Connection] = None
|
account: Account, conn: Connection | None = None
|
||||||
) -> Optional[User]:
|
) -> User | None:
|
||||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||||
wallets = await get_wallets(account.id, False, conn=conn)
|
wallets = await get_wallets(account.id, False, conn=conn)
|
||||||
return User(
|
return User(
|
||||||
@@ -207,7 +205,7 @@ async def update_user_access_control_list(user_acls: UserAcls):
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_access_control_lists(
|
async def get_user_access_control_lists(
|
||||||
user_id: str, conn: Optional[Connection] = None
|
user_id: str, conn: Connection | None = None
|
||||||
) -> UserAcls:
|
) -> UserAcls:
|
||||||
user_acls = await (conn or db).fetchone(
|
user_acls = await (conn or db).fetchone(
|
||||||
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||||
|
|||||||
+20
-23
@@ -1,6 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
@@ -14,8 +13,8 @@ from ..models import Wallet
|
|||||||
async def create_wallet(
|
async def create_wallet(
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
wallet_name: Optional[str] = None,
|
wallet_name: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet_id = uuid4().hex
|
wallet_id = uuid4().hex
|
||||||
wallet = Wallet(
|
wallet = Wallet(
|
||||||
@@ -32,8 +31,8 @@ async def create_wallet(
|
|||||||
|
|
||||||
async def update_wallet(
|
async def update_wallet(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Optional[Wallet]:
|
) -> Wallet:
|
||||||
wallet.updated_at = datetime.now(timezone.utc)
|
wallet.updated_at = datetime.now(timezone.utc)
|
||||||
await (conn or db).update("wallets", wallet)
|
await (conn or db).update("wallets", wallet)
|
||||||
return wallet
|
return wallet
|
||||||
@@ -44,7 +43,7 @@ async def delete_wallet(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
deleted: bool = True,
|
deleted: bool = True,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
now = int(time())
|
now = int(time())
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
@@ -58,9 +57,7 @@ async def delete_wallet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def force_delete_wallet(
|
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
|
||||||
wallet_id: str, conn: Optional[Connection] = None
|
|
||||||
) -> None:
|
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE FROM wallets WHERE id = :wallet",
|
"DELETE FROM wallets WHERE id = :wallet",
|
||||||
{"wallet": wallet_id},
|
{"wallet": wallet_id},
|
||||||
@@ -68,8 +65,8 @@ async def force_delete_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_wallet_by_id(
|
async def delete_wallet_by_id(
|
||||||
wallet_id: str, conn: Optional[Connection] = None
|
wallet_id: str, conn: Connection | None = None
|
||||||
) -> Optional[int]:
|
) -> int | None:
|
||||||
now = int(time())
|
now = int(time())
|
||||||
result = await (conn or db).execute(
|
result = await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||||
@@ -83,13 +80,13 @@ async def delete_wallet_by_id(
|
|||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
||||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
|
||||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||||
|
|
||||||
|
|
||||||
async def delete_unused_wallets(
|
async def delete_unused_wallets(
|
||||||
time_delta: int,
|
time_delta: int,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
delta = int(time()) - time_delta
|
delta = int(time()) - time_delta
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
@@ -107,8 +104,8 @@ async def delete_unused_wallets(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet(
|
async def get_wallet(
|
||||||
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||||
) -> Optional[Wallet]:
|
) -> Wallet | None:
|
||||||
query = """
|
query = """
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
@@ -125,7 +122,7 @@ async def get_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallets(
|
async def get_wallets(
|
||||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||||
) -> list[Wallet]:
|
) -> list[Wallet]:
|
||||||
query = """
|
query = """
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
@@ -144,9 +141,9 @@ async def get_wallets(
|
|||||||
|
|
||||||
async def get_wallets_paginated(
|
async def get_wallets_paginated(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
deleted: Optional[bool] = None,
|
deleted: bool | None = None,
|
||||||
filters: Optional[Filters[WalletsFilters]] = None,
|
filters: Filters[WalletsFilters] | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Page[Wallet]:
|
) -> Page[Wallet]:
|
||||||
if deleted is None:
|
if deleted is None:
|
||||||
deleted = False
|
deleted = False
|
||||||
@@ -166,7 +163,7 @@ async def get_wallets_paginated(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallets_ids(
|
async def get_wallets_ids(
|
||||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||||
if deleted is not None:
|
if deleted is not None:
|
||||||
@@ -186,8 +183,8 @@ async def get_wallets_count():
|
|||||||
|
|
||||||
async def get_wallet_for_key(
|
async def get_wallet_for_key(
|
||||||
key: str,
|
key: str,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Optional[Wallet]:
|
) -> Wallet | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
@@ -201,7 +198,7 @@ async def get_wallet_for_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_total_balance(conn: Optional[Connection] = None):
|
async def get_total_balance(conn: Connection | None = None):
|
||||||
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
return row.get("balance", 0) or 0
|
return row.get("balance", 0) or 0
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
|
|
||||||
from ..models import WebPushSubscription
|
from ..models import WebPushSubscription
|
||||||
@@ -7,7 +5,7 @@ from ..models import WebPushSubscription
|
|||||||
|
|
||||||
async def get_webpush_subscription(
|
async def get_webpush_subscription(
|
||||||
endpoint: str, user: str
|
endpoint: str, user: str
|
||||||
) -> Optional[WebPushSubscription]:
|
) -> WebPushSubscription | None:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM webpush_subscriptions
|
SELECT * FROM webpush_subscriptions
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ from lnbits.settings import settings
|
|||||||
|
|
||||||
|
|
||||||
async def migrate_extension_database(
|
async def migrate_extension_database(
|
||||||
ext: InstallableExtension, current_version: Optional[DbVersion] = None
|
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||||
):
|
):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,7 +38,7 @@ async def run_migration(
|
|||||||
db: Connection,
|
db: Connection,
|
||||||
migrations_module: Any,
|
migrations_module: Any,
|
||||||
db_name: str,
|
db_name: str,
|
||||||
current_version: Optional[DbVersion] = None,
|
current_version: DbVersion | None = None,
|
||||||
):
|
):
|
||||||
matcher = re.compile(r"^m(\d\d\d)_")
|
matcher = re.compile(r"^m(\d\d\d)_")
|
||||||
|
|
||||||
|
|||||||
@@ -735,3 +735,11 @@ async def m032_add_external_id_to_accounts(db: Connection):
|
|||||||
|
|
||||||
async def m033_update_payment_table(db: Connection):
|
async def m033_update_payment_table(db: Connection):
|
||||||
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
|
|||||||
info_notification: str | None
|
info_notification: str | None
|
||||||
critical_notification: str | None
|
critical_notification: str | None
|
||||||
details_link: str | None
|
details_link: str | None
|
||||||
|
paid_features: str | None
|
||||||
pay_link: str | None
|
pay_link: str | None
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
details_link: str | None = None
|
details_link: str | None = None
|
||||||
|
|
||||||
|
paid_features: str | None = None
|
||||||
pay_link: str | None = None
|
pay_link: str | None = None
|
||||||
cost_sats: int | None = None
|
cost_sats: int | None = None
|
||||||
paid_sats: int | None = 0
|
paid_sats: int | None = 0
|
||||||
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
details_link=e.details_link,
|
details_link=e.details_link,
|
||||||
pay_link=e.pay_link,
|
pay_link=e.pay_link,
|
||||||
|
paid_features=e.paid_features,
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
|
|||||||
dependencies: list[str] = []
|
dependencies: list[str] = []
|
||||||
archive: str | None = None
|
archive: str | None = None
|
||||||
featured: bool = False
|
featured: bool = False
|
||||||
|
paid_features: str | None = None
|
||||||
|
has_paid_release: bool = False
|
||||||
|
has_free_release: bool = False
|
||||||
|
|
||||||
|
|
||||||
class InstallableExtension(BaseModel):
|
class InstallableExtension(BaseModel):
|
||||||
@@ -409,7 +415,6 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||||
shutil.rmtree(tmp_dir, True)
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||||
zip_ref.extractall(tmp_dir)
|
zip_ref.extractall(tmp_dir)
|
||||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||||
@@ -452,9 +457,23 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||||
|
|
||||||
def check_latest_version(self, release: ExtensionRelease | None):
|
def check_release_updates(self, release: ExtensionRelease | None):
|
||||||
|
self._check_latest_version(release)
|
||||||
|
self._check_payment_link(release)
|
||||||
|
|
||||||
|
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||||
|
if not pay_link or not self.meta or not self.meta.payments:
|
||||||
|
return None
|
||||||
|
return next(
|
||||||
|
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_latest_version(self, release: ExtensionRelease | None):
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
|
if not release.is_version_compatible:
|
||||||
|
return
|
||||||
if not self.meta or not self.meta.latest_release:
|
if not self.meta or not self.meta.latest_release:
|
||||||
meta = self.meta or ExtensionMeta()
|
meta = self.meta or ExtensionMeta()
|
||||||
meta.latest_release = release
|
meta.latest_release = release
|
||||||
@@ -465,13 +484,19 @@ class InstallableExtension(BaseModel):
|
|||||||
):
|
):
|
||||||
self.meta.latest_release = release
|
self.meta.latest_release = release
|
||||||
|
|
||||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
def _check_payment_link(self, release: ExtensionRelease | None):
|
||||||
if not pay_link or not self.meta or not self.meta.payments:
|
if not release:
|
||||||
return None
|
return
|
||||||
return next(
|
if not release.is_version_compatible:
|
||||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
return
|
||||||
None,
|
if not self.meta:
|
||||||
)
|
self.meta = ExtensionMeta()
|
||||||
|
if release.pay_link:
|
||||||
|
self.meta.has_paid_release = True
|
||||||
|
else:
|
||||||
|
self.meta.has_free_release = True
|
||||||
|
if release.paid_features:
|
||||||
|
self.meta.paid_features = release.paid_features
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
def _restore_payment_info(self):
|
||||||
if (
|
if (
|
||||||
@@ -597,7 +622,7 @@ class InstallableExtension(BaseModel):
|
|||||||
(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 and ext.meta:
|
||||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
existing_ext.check_release_updates(ext.meta.latest_release)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
@@ -611,10 +636,10 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == e.id), None
|
(ee for ee in extension_list if ee.id == e.id), None
|
||||||
)
|
)
|
||||||
if existing_ext:
|
if existing_ext:
|
||||||
existing_ext.check_latest_version(release)
|
existing_ext.check_release_updates(release)
|
||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_latest_version(release)
|
ext.check_release_updates(release)
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
meta.featured = ext.id in manifest.featured
|
meta.featured = ext.id in manifest.featured
|
||||||
ext.meta = meta
|
ext.meta = meta
|
||||||
@@ -628,8 +653,11 @@ class InstallableExtension(BaseModel):
|
|||||||
@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] = []
|
||||||
|
all_manifests = [
|
||||||
for url in settings.lnbits_extensions_manifests:
|
*settings.lnbits_extensions_manifests,
|
||||||
|
settings.lnbits_extensions_builder_manifest_url,
|
||||||
|
]
|
||||||
|
for url in all_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, validator
|
||||||
|
|
||||||
|
from lnbits.helpers import (
|
||||||
|
camel_to_snake,
|
||||||
|
is_camel_case,
|
||||||
|
is_snake_case,
|
||||||
|
urlsafe_short_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataField(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
label: str | None = None
|
||||||
|
hint: str | None = None
|
||||||
|
optional: bool = False
|
||||||
|
editable: bool = False
|
||||||
|
searchable: bool = False
|
||||||
|
sortable: bool = False
|
||||||
|
fields: list[DataField] = []
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.name = self.name.strip()
|
||||||
|
self.type = self.type.strip()
|
||||||
|
if self.label:
|
||||||
|
self.label = self.label.strip()
|
||||||
|
if self.hint:
|
||||||
|
self.hint = self.hint.strip()
|
||||||
|
if self.type == "json":
|
||||||
|
self.editable = False
|
||||||
|
self.searchable = False
|
||||||
|
self.sortable = False
|
||||||
|
else:
|
||||||
|
self.fields = []
|
||||||
|
|
||||||
|
for field in self.fields:
|
||||||
|
field.normalize()
|
||||||
|
|
||||||
|
def field_to_py(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
field_type = self.type
|
||||||
|
if self.type == "json":
|
||||||
|
field_type = "dict"
|
||||||
|
elif self.type in ["wallet", "currency", "text"]:
|
||||||
|
field_type = "str"
|
||||||
|
if self.optional:
|
||||||
|
field_type += " | None"
|
||||||
|
if self.type == "currency":
|
||||||
|
field_type += ' = "sat"'
|
||||||
|
return f"{field_name}: {field_type}"
|
||||||
|
|
||||||
|
def field_to_js(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
default_value = "null"
|
||||||
|
if self.type == "json":
|
||||||
|
default_value = "{}"
|
||||||
|
if self.type == "currency":
|
||||||
|
default_value = '"sat"'
|
||||||
|
return f"{field_name}: {default_value}"
|
||||||
|
|
||||||
|
def field_to_ui_table_column(self) -> str:
|
||||||
|
column = {
|
||||||
|
"name": self.name,
|
||||||
|
"align": "left",
|
||||||
|
"label": self.label or self.name,
|
||||||
|
"field": self.name,
|
||||||
|
"sortable": self.sortable,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(column)
|
||||||
|
|
||||||
|
def field_to_db(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
field_type = self.type
|
||||||
|
if field_type == "str":
|
||||||
|
db_type = "TEXT"
|
||||||
|
elif field_type == "int":
|
||||||
|
db_type = "INT"
|
||||||
|
elif field_type == "float":
|
||||||
|
db_type = "REAL"
|
||||||
|
elif field_type == "bool":
|
||||||
|
db_type = "BOOLEAN"
|
||||||
|
elif field_type == "datetime":
|
||||||
|
db_type = "TIMESTAMP"
|
||||||
|
else:
|
||||||
|
db_type = "TEXT"
|
||||||
|
|
||||||
|
db_field = f"{field_name} {db_type}"
|
||||||
|
if not self.optional:
|
||||||
|
db_field += " NOT NULL"
|
||||||
|
if field_type == "json":
|
||||||
|
db_field += " DEFAULT '{empty_dict}'"
|
||||||
|
return db_field
|
||||||
|
|
||||||
|
def field_mock_value(self, index: int) -> Any:
|
||||||
|
if self.name == "id":
|
||||||
|
return urlsafe_short_hash()
|
||||||
|
if self.type == "int":
|
||||||
|
return index
|
||||||
|
elif self.type == "float":
|
||||||
|
return float(f"{index}.0{index * 2}")
|
||||||
|
elif self.type == "bool":
|
||||||
|
return True if index % 2 == 0 else False
|
||||||
|
elif self.type == "datetime":
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
|
||||||
|
elif self.type == "json":
|
||||||
|
return {"key": "value"}
|
||||||
|
elif self.type == "currency":
|
||||||
|
return "USD"
|
||||||
|
else:
|
||||||
|
return f"{self.name} {index}"
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Field name is required.")
|
||||||
|
if not is_snake_case(v):
|
||||||
|
raise ValueError(f"Field Name must be snake_case. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("type")
|
||||||
|
def validate_type(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Owner Data type is required")
|
||||||
|
if v not in [
|
||||||
|
"str",
|
||||||
|
"int",
|
||||||
|
"float",
|
||||||
|
"bool",
|
||||||
|
"datetime",
|
||||||
|
"json",
|
||||||
|
"wallet",
|
||||||
|
"currency",
|
||||||
|
"text",
|
||||||
|
]:
|
||||||
|
raise ValueError(
|
||||||
|
"Field Type must be one of: "
|
||||||
|
"str, int, float, bool, datetime, json, wallet, currency, text."
|
||||||
|
f" Found: {v}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("label")
|
||||||
|
def validate_label(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field label cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("hint")
|
||||||
|
def validate_hint(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DataFields(BaseModel):
|
||||||
|
name: str
|
||||||
|
editable: bool = True
|
||||||
|
fields: list[DataField] = []
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
self.normalize()
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.name = self.name.strip()
|
||||||
|
for field in self.fields:
|
||||||
|
field.normalize()
|
||||||
|
if all(not field.editable for field in self.fields):
|
||||||
|
self.editable = False
|
||||||
|
|
||||||
|
def get_field_by_name(self, name: str | None) -> DataField | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
for field in self.fields:
|
||||||
|
if field.name == name:
|
||||||
|
return field
|
||||||
|
return None
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Data fields name is required")
|
||||||
|
if not is_camel_case(v):
|
||||||
|
raise ValueError(f"Data name must be CamelCase. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsFields(DataFields):
|
||||||
|
enabled: bool = False
|
||||||
|
type: str = "user"
|
||||||
|
|
||||||
|
@validator("type")
|
||||||
|
def validate_type(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Settings type is required")
|
||||||
|
if v not in ["user", "admin"]:
|
||||||
|
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ActionFields(BaseModel):
|
||||||
|
generate_action: bool = False
|
||||||
|
generate_payment_logic: bool = False
|
||||||
|
wallet_id: str | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
amount: str | None = None
|
||||||
|
amount_source: Literal["owner_data", "client_data"] | None = None
|
||||||
|
paid_flag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerDataFields(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClientDataFields(BaseModel):
|
||||||
|
public_inputs: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPageFields(BaseModel):
|
||||||
|
has_public_page: bool = False
|
||||||
|
owner_data_fields: OwnerDataFields
|
||||||
|
client_data_fields: ClientDataFields
|
||||||
|
action_fields: ActionFields
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewAction(BaseModel):
|
||||||
|
is_preview_mode: bool = False
|
||||||
|
is_settings_preview: bool = False
|
||||||
|
is_owner_data_preview: bool = False
|
||||||
|
is_client_data_preview: bool = False
|
||||||
|
is_public_page_preview: bool = False
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
if not self.is_preview_mode:
|
||||||
|
self.is_settings_preview = False
|
||||||
|
self.is_owner_data_preview = False
|
||||||
|
self.is_client_data_preview = False
|
||||||
|
self.is_public_page_preview = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionData(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
stub_version: str | None
|
||||||
|
short_description: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
owner_data: DataFields
|
||||||
|
client_data: DataFields
|
||||||
|
settings_data: SettingsFields
|
||||||
|
public_page: PublicPageFields
|
||||||
|
preview_action: PreviewAction = PreviewAction()
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
self.validate_data()
|
||||||
|
self.normalize()
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.id = self.id.strip()
|
||||||
|
self.name = self.name.strip()
|
||||||
|
if self.stub_version:
|
||||||
|
self.stub_version = self.stub_version.strip()
|
||||||
|
if self.short_description:
|
||||||
|
self.short_description = self.short_description.strip()
|
||||||
|
if self.description:
|
||||||
|
self.description = self.description.strip()
|
||||||
|
if not self.public_page.has_public_page:
|
||||||
|
self.public_page.action_fields.generate_action = False
|
||||||
|
self.public_page.action_fields.generate_payment_logic = False
|
||||||
|
if not self.public_page.action_fields.generate_action:
|
||||||
|
self.public_page.action_fields.generate_payment_logic = False
|
||||||
|
|
||||||
|
def validate_data(self) -> None:
|
||||||
|
self._validate_field_names()
|
||||||
|
self._validate_public_page_fields()
|
||||||
|
self._validate_action_fields()
|
||||||
|
|
||||||
|
def _validate_public_page_fields(self) -> None:
|
||||||
|
if not self.public_page.has_public_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
public_page_name = self.public_page.owner_data_fields.name
|
||||||
|
if public_page_name:
|
||||||
|
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
|
||||||
|
if not public_page_name_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Name must be one of the owner data fields."
|
||||||
|
f" Received: {public_page_name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
public_page_description = self.public_page.owner_data_fields.description
|
||||||
|
if public_page_description:
|
||||||
|
public_page_description_field = self.owner_data.get_field_by_name(
|
||||||
|
public_page_description
|
||||||
|
)
|
||||||
|
if not public_page_description_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Description must be one of the owner data fields."
|
||||||
|
f" Received: {public_page_description}."
|
||||||
|
)
|
||||||
|
|
||||||
|
public_page_inputs = self.public_page.client_data_fields.public_inputs
|
||||||
|
if public_page_inputs:
|
||||||
|
for input_field in public_page_inputs:
|
||||||
|
input_field_obj = self.client_data.get_field_by_name(input_field)
|
||||||
|
if not input_field_obj:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Input fields"
|
||||||
|
" must be one of the client data fields."
|
||||||
|
f" Received: {input_field}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_action_fields(self) -> None:
|
||||||
|
if not self.public_page.action_fields.generate_action:
|
||||||
|
return
|
||||||
|
if not self.public_page.action_fields.generate_payment_logic:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._validate_owner_data_fields()
|
||||||
|
self._validate_client_data_fields()
|
||||||
|
|
||||||
|
def _validate_owner_data_fields(self) -> None:
|
||||||
|
wallet_id = self.public_page.action_fields.wallet_id
|
||||||
|
if wallet_id:
|
||||||
|
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
|
||||||
|
if not wallet_id_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Wallet ID must be one of the owner data fields."
|
||||||
|
f" Received: {wallet_id}."
|
||||||
|
)
|
||||||
|
if wallet_id_field.type != "wallet":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Wallet ID field type must be 'wallet'."
|
||||||
|
f" Received: {wallet_id_field.type}."
|
||||||
|
)
|
||||||
|
currency = self.public_page.action_fields.currency
|
||||||
|
if currency:
|
||||||
|
currency_field = self.owner_data.get_field_by_name(currency)
|
||||||
|
if not currency_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Currency must be one of the owner data fields."
|
||||||
|
f" Received: {currency}."
|
||||||
|
)
|
||||||
|
if currency_field.type != "currency":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Currency field type must be 'currency'."
|
||||||
|
f" Received: {currency_field.type}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_field_names(self) -> None:
|
||||||
|
reserved_names = {"id", "created_at", "updated_at"}
|
||||||
|
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_client_data_fields(self) -> None:
|
||||||
|
amount = self.public_page.action_fields.amount
|
||||||
|
amount_source = self.public_page.action_fields.amount_source
|
||||||
|
if amount_source and amount:
|
||||||
|
if amount_source == "owner_data":
|
||||||
|
amount_field = self.owner_data.get_field_by_name(amount)
|
||||||
|
else:
|
||||||
|
amount_field = self.client_data.get_field_by_name(amount)
|
||||||
|
if not amount_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Amount must be one of the "
|
||||||
|
"client data or owner data fields."
|
||||||
|
f" Received: {amount}."
|
||||||
|
)
|
||||||
|
if amount_field.type not in ["int", "float"]:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Amount field type must be 'int' or 'float'."
|
||||||
|
f" Received: {amount_field.type}."
|
||||||
|
)
|
||||||
|
paid_flag = self.public_page.action_fields.paid_flag
|
||||||
|
if paid_flag:
|
||||||
|
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
|
||||||
|
if not paid_flag_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Paid Flag must be one of the client data fields."
|
||||||
|
f" Received: {paid_flag}."
|
||||||
|
)
|
||||||
|
if paid_flag_field.type != "bool":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Paid Flag field type must be 'bool'."
|
||||||
|
f" Received: {paid_flag_field.type}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@validator("id")
|
||||||
|
def validate_id(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Extension ID is required")
|
||||||
|
if not is_snake_case(v):
|
||||||
|
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Extension name is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("stub_version")
|
||||||
|
def validate_stub_version(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Extension stub version cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("short_description")
|
||||||
|
def validate_short_description(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field short description cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("description")
|
||||||
|
def validate_description(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field description cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("owner_data")
|
||||||
|
def validate_owner_data(cls, v: DataFields) -> DataFields:
|
||||||
|
if len(v.fields) == 0:
|
||||||
|
raise ValueError("At least one owner data field is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("client_data")
|
||||||
|
def validate_client_data(cls, v: DataFields) -> DataFields:
|
||||||
|
if len(v.fields) == 0:
|
||||||
|
raise ValueError("At least one client data field is required")
|
||||||
|
return v
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
from typing import Optional
|
from time import time
|
||||||
|
|
||||||
from lnurl import Lnurl, LnurlPayResponse
|
from lnurl import LnAddress, Lnurl, LnurlPayResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class CreateLnurlPayment(BaseModel):
|
class CreateLnurlPayment(BaseModel):
|
||||||
res: LnurlPayResponse
|
res: LnurlPayResponse | None = None
|
||||||
|
lnurl: Lnurl | LnAddress | None = None
|
||||||
amount: int
|
amount: int
|
||||||
comment: Optional[str] = None
|
comment: str | None = None
|
||||||
unit: Optional[str] = None
|
unit: str | None = None
|
||||||
internal_memo: Optional[str] = None
|
internal_memo: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateLnurlWithdraw(BaseModel):
|
class CreateLnurlWithdraw(BaseModel):
|
||||||
lnurl_w: Lnurl
|
lnurl_w: Lnurl
|
||||||
|
|
||||||
|
|
||||||
|
class LnurlScan(BaseModel):
|
||||||
|
lnurl: Lnurl | LnAddress
|
||||||
|
|
||||||
|
|
||||||
|
class StoredPayLink(BaseModel):
|
||||||
|
lnurl: str
|
||||||
|
label: str
|
||||||
|
last_used: int = Field(default_factory=lambda: int(time()))
|
||||||
|
|
||||||
|
|
||||||
|
class StoredPayLinks(BaseModel):
|
||||||
|
links: list[StoredPayLink] = []
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from ecdsa import SECP256k1, SigningKey
|
|
||||||
from lnurl import encode as lnurl_encode
|
from lnurl import encode as lnurl_encode
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from lnbits.core.models.lnurl import StoredPayLinks
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -41,6 +39,7 @@ class Wallet(BaseModel):
|
|||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
balance_msat: int = Field(default=0, no_database=True)
|
balance_msat: int = Field(default=0, no_database=True)
|
||||||
extra: WalletExtra = WalletExtra()
|
extra: WalletExtra = WalletExtra()
|
||||||
|
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
@@ -58,14 +57,6 @@ class Wallet(BaseModel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
|
||||||
hashing_key = hashlib.sha256(self.id.encode()).digest()
|
|
||||||
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
|
|
||||||
|
|
||||||
return SigningKey.from_string(
|
|
||||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateWallet(BaseModel):
|
class CreateWallet(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from .funding_source import (
|
|||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
switch_to_voidwallet,
|
switch_to_voidwallet,
|
||||||
)
|
)
|
||||||
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
|
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
|
||||||
from .notifications import enqueue_admin_notification, send_payment_notification
|
from .notifications import enqueue_admin_notification, send_payment_notification
|
||||||
from .payments import (
|
from .payments import (
|
||||||
calculate_fiat_amounts,
|
calculate_fiat_amounts,
|
||||||
@@ -57,6 +57,7 @@ __all__ = [
|
|||||||
"get_payments_daily_stats",
|
"get_payments_daily_stats",
|
||||||
"get_pr_from_lnurl",
|
"get_pr_from_lnurl",
|
||||||
"pay_invoice",
|
"pay_invoice",
|
||||||
|
"perform_withdraw",
|
||||||
"send_payment_notification",
|
"send_payment_notification",
|
||||||
"service_fee",
|
"service_fee",
|
||||||
"settle_hold_invoice",
|
"settle_hold_invoice",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -22,7 +21,9 @@ from lnbits.settings import settings
|
|||||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||||
|
|
||||||
|
|
||||||
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
async def install_extension(
|
||||||
|
ext_info: InstallableExtension, skip_download: bool | None = False
|
||||||
|
) -> Extension:
|
||||||
|
|
||||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
|||||||
if installed_ext and installed_ext.meta:
|
if installed_ext and installed_ext.meta:
|
||||||
ext_info.meta.payments = installed_ext.meta.payments
|
ext_info.meta.payments = installed_ext.meta.payments
|
||||||
|
|
||||||
await ext_info.download_archive()
|
if not skip_download:
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
@@ -74,11 +76,13 @@ async def uninstall_extension(ext_id: str):
|
|||||||
async def activate_extension(ext: Extension):
|
async def activate_extension(ext: Extension):
|
||||||
core_app_extra.register_new_ext_routes(ext)
|
core_app_extra.register_new_ext_routes(ext)
|
||||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||||
|
await start_extension_background_work(ext.code)
|
||||||
|
|
||||||
|
|
||||||
async def deactivate_extension(ext_id: str):
|
async def deactivate_extension(ext_id: str):
|
||||||
settings.deactivate_extension_paths(ext_id)
|
settings.deactivate_extension_paths(ext_id)
|
||||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||||
|
await stop_extension_background_work(ext_id)
|
||||||
|
|
||||||
|
|
||||||
async def stop_extension_background_work(ext_id: str) -> bool:
|
async def stop_extension_background_work(ext_id: str) -> bool:
|
||||||
@@ -145,7 +149,7 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def get_valid_extensions(
|
async def get_valid_extensions(
|
||||||
include_deactivated: Optional[bool] = True,
|
include_deactivated: bool | None = True,
|
||||||
) -> list[Extension]:
|
) -> list[Extension]:
|
||||||
installed_extensions = await get_installed_extensions()
|
installed_extensions = await get_installed_extensions()
|
||||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||||
@@ -164,8 +168,8 @@ async def get_valid_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def get_valid_extension(
|
async def get_valid_extension(
|
||||||
ext_id: str, include_deactivated: Optional[bool] = True
|
ext_id: str, include_deactivated: bool | None = True
|
||||||
) -> Optional[Extension]:
|
) -> Extension | None:
|
||||||
ext = await get_installed_extension(ext_id)
|
ext = await get_installed_extension(ext_id)
|
||||||
if not ext:
|
if not ext:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import shortuuid
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.models.extensions import ExtensionRelease, InstallableExtension
|
||||||
|
from lnbits.core.models.extensions_builder import DataField, ExtensionData
|
||||||
|
from lnbits.db import dict_to_model
|
||||||
|
from lnbits.helpers import (
|
||||||
|
camel_to_snake,
|
||||||
|
camel_to_words,
|
||||||
|
download_url,
|
||||||
|
lowercase_first_letter,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
py_files = [
|
||||||
|
"__init__.py",
|
||||||
|
"models.py",
|
||||||
|
"migrations.py",
|
||||||
|
"views_api.py",
|
||||||
|
"crud.py",
|
||||||
|
"views.py",
|
||||||
|
"tasks.py",
|
||||||
|
"services.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
remove_line_marker = "{remove_line_marker}}"
|
||||||
|
|
||||||
|
ui_table_columns = [
|
||||||
|
DataField(
|
||||||
|
name="updated_at",
|
||||||
|
type="datetime",
|
||||||
|
label="Updated At",
|
||||||
|
hint="Timestamp of the last update",
|
||||||
|
optional=False,
|
||||||
|
editable=False,
|
||||||
|
searchable=False,
|
||||||
|
sortable=True,
|
||||||
|
),
|
||||||
|
DataField(
|
||||||
|
name="id",
|
||||||
|
type="str",
|
||||||
|
label="ID",
|
||||||
|
hint="Unique identifier",
|
||||||
|
optional=False,
|
||||||
|
editable=False,
|
||||||
|
searchable=False,
|
||||||
|
sortable=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
excluded_dirs = {"./.", "./__pycache__", "./node_modules", "./transform"}
|
||||||
|
|
||||||
|
|
||||||
|
async def build_extension_from_data(
|
||||||
|
data: ExtensionData, stub_ext_id: str, working_dir_name: str | None = None
|
||||||
|
):
|
||||||
|
release = await _get_extension_stub_release(stub_ext_id, data.stub_version)
|
||||||
|
release.hash = sha256(uuid4().hex.encode("utf-8")).hexdigest()
|
||||||
|
release.icon = f"/{data.id}/static/image/{data.id}.png"
|
||||||
|
release.is_github_release = False
|
||||||
|
await _fetch_extension_builder_stub(stub_ext_id, release)
|
||||||
|
build_dir = _copy_ext_stub_to_build_dir(
|
||||||
|
stub_ext_id=stub_ext_id,
|
||||||
|
stub_version=release.version,
|
||||||
|
new_ext_id=data.id,
|
||||||
|
working_dir_name=working_dir_name,
|
||||||
|
)
|
||||||
|
_transform_extension_builder_stub(data, build_dir)
|
||||||
|
_export_extension_data_json(data, build_dir)
|
||||||
|
return release, build_dir
|
||||||
|
|
||||||
|
|
||||||
|
def clean_extension_builder_data() -> None:
|
||||||
|
working_dir = Path(settings.extension_builder_working_dir_path)
|
||||||
|
if working_dir.is_dir():
|
||||||
|
shutil.rmtree(working_dir, True)
|
||||||
|
working_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
||||||
|
_replace_jinja_placeholders(data, extension_dir)
|
||||||
|
_rename_extension_builder_stub(data, extension_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
|
||||||
|
json.dump(
|
||||||
|
data.dict(),
|
||||||
|
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
|
||||||
|
indent=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_extension_stub_release(
|
||||||
|
stub_ext_id: str, stub_version: str | None = None
|
||||||
|
) -> ExtensionRelease:
|
||||||
|
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
||||||
|
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
release_cache_file = Path(cache_dir, "release.json")
|
||||||
|
|
||||||
|
if stub_version:
|
||||||
|
cached_release = _load_extension_stub_release_from_cache(
|
||||||
|
stub_ext_id, stub_version
|
||||||
|
)
|
||||||
|
if cached_release:
|
||||||
|
logger.debug(f"Loading release from cache {stub_ext_id} ({stub_version}).")
|
||||||
|
return cached_release
|
||||||
|
|
||||||
|
releases: list[ExtensionRelease] = (
|
||||||
|
await InstallableExtension.get_extension_releases(stub_ext_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
release = next((r for r in releases if r.version == stub_version), None)
|
||||||
|
|
||||||
|
if not release and len(releases) > 0:
|
||||||
|
release = releases[0]
|
||||||
|
|
||||||
|
if not release:
|
||||||
|
raise ValueError(f"Release {stub_ext_id} ({stub_version}) not found.")
|
||||||
|
|
||||||
|
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
|
||||||
|
with open(release_cache_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(release.dict(), indent=4))
|
||||||
|
|
||||||
|
return release
|
||||||
|
|
||||||
|
|
||||||
|
def _load_extension_stub_release_from_cache(
|
||||||
|
stub_ext_id: str, stub_version: str
|
||||||
|
) -> ExtensionRelease | None:
|
||||||
|
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
||||||
|
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
||||||
|
release_cache_file = Path(cache_dir, "release.json")
|
||||||
|
if release_cache_file.is_file():
|
||||||
|
with open(release_cache_file, encoding="utf-8") as f:
|
||||||
|
return dict_to_model(json.load(f), ExtensionRelease)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_extension_builder_stub(
|
||||||
|
stub_ext_id: str, release: ExtensionRelease
|
||||||
|
) -> Path:
|
||||||
|
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
||||||
|
cache_dir = Path(working_dir, f"cache-{release.version}")
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stub_ext_zip_path = Path(cache_dir, release.version + ".zip")
|
||||||
|
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
|
||||||
|
|
||||||
|
if not stub_ext_zip_path.is_file():
|
||||||
|
await asyncio.to_thread(download_url, release.archive_url, stub_ext_zip_path)
|
||||||
|
shutil.rmtree(ext_stub_cache_dir, True)
|
||||||
|
|
||||||
|
if not ext_stub_cache_dir.is_dir():
|
||||||
|
tmp_dir = Path(cache_dir, "tmp")
|
||||||
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(stub_ext_zip_path, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(tmp_dir)
|
||||||
|
generated_dir = Path(tmp_dir, os.listdir(tmp_dir)[0])
|
||||||
|
shutil.copytree(generated_dir, Path(ext_stub_cache_dir))
|
||||||
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
|
return ext_stub_cache_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_ext_stub_to_build_dir(
|
||||||
|
stub_ext_id: str,
|
||||||
|
stub_version: str,
|
||||||
|
new_ext_id: str,
|
||||||
|
working_dir_name: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
||||||
|
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
||||||
|
|
||||||
|
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
|
||||||
|
if not ext_stub_cache_dir.is_dir():
|
||||||
|
raise ValueError(
|
||||||
|
f"Extension stub cache dir not found: {stub_ext_id} ({stub_version})"
|
||||||
|
)
|
||||||
|
|
||||||
|
working_dir_name = working_dir_name or f"ext-{int(time())}-{shortuuid.uuid()}"
|
||||||
|
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
|
||||||
|
shutil.rmtree(ext_build_dir, True)
|
||||||
|
|
||||||
|
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
|
||||||
|
return ext_build_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
||||||
|
parsed_data = _parse_extension_data(data)
|
||||||
|
for py_file in py_files:
|
||||||
|
template_path = Path(ext_stub_dir, py_file).as_posix()
|
||||||
|
rederer = _render_file(template_path, parsed_data)
|
||||||
|
with open(template_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(rederer)
|
||||||
|
|
||||||
|
_remove_lines_with_string(template_path, remove_line_marker)
|
||||||
|
|
||||||
|
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
|
||||||
|
rederer = _render_file(
|
||||||
|
template_path, {"preview": data.preview_action, **parsed_data}
|
||||||
|
)
|
||||||
|
embeded_index_js = rederer
|
||||||
|
with open(template_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(rederer)
|
||||||
|
|
||||||
|
_remove_lines_with_string(template_path, remove_line_marker)
|
||||||
|
|
||||||
|
owner_inputs = _fields_to_html_input(
|
||||||
|
[f for f in data.owner_data.fields if f.editable],
|
||||||
|
"ownerDataFormDialog.data",
|
||||||
|
ext_stub_dir,
|
||||||
|
)
|
||||||
|
client_inputs = _fields_to_html_input(
|
||||||
|
[f for f in data.client_data.fields if f.editable],
|
||||||
|
"clientDataFormDialog.data",
|
||||||
|
ext_stub_dir,
|
||||||
|
)
|
||||||
|
settings_inputs = _fields_to_html_input(
|
||||||
|
[f for f in data.settings_data.fields if f.editable],
|
||||||
|
"settingsFormDialog.data",
|
||||||
|
ext_stub_dir,
|
||||||
|
)
|
||||||
|
template_path = Path(
|
||||||
|
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
|
||||||
|
).as_posix()
|
||||||
|
rederer = _render_file(
|
||||||
|
template_path,
|
||||||
|
{
|
||||||
|
"embeded_index_js": embeded_index_js,
|
||||||
|
"extension_builder_stub_owner_inputs": owner_inputs,
|
||||||
|
"extension_builder_stub_settings_inputs": settings_inputs,
|
||||||
|
"extension_builder_stub_client_inputs": client_inputs,
|
||||||
|
"preview": data.preview_action,
|
||||||
|
"cancel_comment": remove_line_marker,
|
||||||
|
**parsed_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with open(template_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(rederer)
|
||||||
|
|
||||||
|
_remove_lines_with_string(template_path, remove_line_marker)
|
||||||
|
|
||||||
|
public_client_inputs = _fields_to_html_input(
|
||||||
|
[
|
||||||
|
f
|
||||||
|
for f in data.client_data.fields
|
||||||
|
if f.name in data.public_page.client_data_fields.public_inputs
|
||||||
|
],
|
||||||
|
"publicClientData",
|
||||||
|
ext_stub_dir,
|
||||||
|
)
|
||||||
|
public_template_path = Path(
|
||||||
|
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
|
||||||
|
)
|
||||||
|
template_path = public_template_path.as_posix()
|
||||||
|
if not data.public_page.has_public_page:
|
||||||
|
public_template_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
rederer = _render_file(
|
||||||
|
template_path,
|
||||||
|
{
|
||||||
|
"extension_builder_stub_public_client_inputs": public_client_inputs,
|
||||||
|
"preview": data.preview_action,
|
||||||
|
**data.public_page.action_fields.dict(),
|
||||||
|
"cancel_comment": remove_line_marker,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(template_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(rederer)
|
||||||
|
|
||||||
|
_remove_lines_with_string(template_path, remove_line_marker)
|
||||||
|
|
||||||
|
|
||||||
|
def zip_directory(source_dir, zip_path):
|
||||||
|
"""
|
||||||
|
Zips the contents of a directory (including subdirectories and files).
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- source_dir (str): The path of the directory to zip.
|
||||||
|
- zip_path (str): The path where the .zip file will be saved.
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for root, _, files in os.walk(source_dir):
|
||||||
|
if _is_excluded_dir(root):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
# Add file with a relative path inside the zip
|
||||||
|
relative_path = os.path.relpath(full_path, start=source_dir)
|
||||||
|
zipf.write(full_path, arcname=relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
||||||
|
extension_dir_path = extension_dir.as_posix()
|
||||||
|
rename_values = {
|
||||||
|
"extension_builder_stub_name": data.name,
|
||||||
|
"extension_builder_stub_short_description": data.short_description or "",
|
||||||
|
"extension_builder_stub": data.id,
|
||||||
|
"OwnerData": data.owner_data.name,
|
||||||
|
"ownerData": lowercase_first_letter(data.owner_data.name),
|
||||||
|
"Owner Data": camel_to_words(data.owner_data.name),
|
||||||
|
"owner data": camel_to_words(data.owner_data.name).lower(),
|
||||||
|
"owner_data": camel_to_snake(data.owner_data.name),
|
||||||
|
"ClientData": data.client_data.name,
|
||||||
|
"clientData": lowercase_first_letter(data.client_data.name),
|
||||||
|
"Client Data": camel_to_words(data.client_data.name),
|
||||||
|
"client data": camel_to_words(data.client_data.name).lower(),
|
||||||
|
"client_data": camel_to_snake(data.client_data.name),
|
||||||
|
}
|
||||||
|
for old_text, new_text in rename_values.items():
|
||||||
|
_replace_text_in_files(
|
||||||
|
directory=extension_dir_path,
|
||||||
|
old_text=old_text,
|
||||||
|
new_text=new_text,
|
||||||
|
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_rename_files_and_dirs_in_directory(
|
||||||
|
directory=extension_dir_path,
|
||||||
|
old_text="extension_builder_stub",
|
||||||
|
new_text=data.id,
|
||||||
|
)
|
||||||
|
_rename_files_and_dirs_in_directory(
|
||||||
|
directory=extension_dir_path,
|
||||||
|
old_text="owner_data",
|
||||||
|
new_text=camel_to_snake(data.owner_data.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_text_in_files(
|
||||||
|
directory: str,
|
||||||
|
old_text: str,
|
||||||
|
new_text: str,
|
||||||
|
file_extensions: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Recursively replaces text in all files under the given directory.
|
||||||
|
"""
|
||||||
|
for root, _, files in os.walk(directory):
|
||||||
|
if _is_excluded_dir(root):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
if file_extensions:
|
||||||
|
if not any(filename.endswith(ext) for ext in file_extensions):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = os.path.join(root, filename)
|
||||||
|
try:
|
||||||
|
with open(file_path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
if old_text in content:
|
||||||
|
new_content = content.replace(old_text, new_text)
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_content)
|
||||||
|
logger.trace(f"Updated: {file_path}")
|
||||||
|
except (UnicodeDecodeError, PermissionError, FileNotFoundError) as e:
|
||||||
|
logger.debug(f"Skipped {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_file(template_path: str, data: dict) -> str:
|
||||||
|
# Extract directory and file name
|
||||||
|
template_dir = os.path.dirname(template_path)
|
||||||
|
template_file = os.path.basename(template_path)
|
||||||
|
|
||||||
|
# Create Jinja environment
|
||||||
|
# env = Environment(loader=FileSystemLoader(template_dir))
|
||||||
|
env = _jinja_env(template_dir)
|
||||||
|
template = env.get_template(template_file)
|
||||||
|
|
||||||
|
# Render the template with data
|
||||||
|
return template.render(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def _jinja_env(template_dir: str) -> Environment:
|
||||||
|
return Environment(
|
||||||
|
loader=FileSystemLoader(template_dir),
|
||||||
|
variable_start_string="<<",
|
||||||
|
variable_end_string=">>",
|
||||||
|
block_start_string="<%",
|
||||||
|
block_end_string="%>",
|
||||||
|
comment_start_string="<#",
|
||||||
|
comment_end_string="#>",
|
||||||
|
autoescape=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extension_data(data: ExtensionData) -> dict:
|
||||||
|
return {
|
||||||
|
"owner_data": {
|
||||||
|
"name": data.owner_data.name,
|
||||||
|
"editable": data.owner_data.editable,
|
||||||
|
"js_fields": [
|
||||||
|
field.field_to_js()
|
||||||
|
for field in data.owner_data.fields
|
||||||
|
if field.editable
|
||||||
|
],
|
||||||
|
"search_fields": [
|
||||||
|
camel_to_snake(field.name)
|
||||||
|
for field in data.owner_data.fields
|
||||||
|
if field.searchable
|
||||||
|
],
|
||||||
|
"ui_table_columns": [
|
||||||
|
field.field_to_ui_table_column()
|
||||||
|
for field in data.owner_data.fields + ui_table_columns
|
||||||
|
if field.sortable
|
||||||
|
],
|
||||||
|
"ui_mock_data": [
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
field.name: field.field_mock_value(index=index)
|
||||||
|
for field in data.owner_data.fields + ui_table_columns
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for index in range(1, 5)
|
||||||
|
],
|
||||||
|
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
|
||||||
|
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
|
||||||
|
},
|
||||||
|
"client_data": {
|
||||||
|
"name": data.client_data.name,
|
||||||
|
"editable": data.client_data.editable,
|
||||||
|
"search_fields": [
|
||||||
|
camel_to_snake(field.name)
|
||||||
|
for field in data.client_data.fields
|
||||||
|
if field.searchable
|
||||||
|
],
|
||||||
|
"ui_table_columns": [
|
||||||
|
field.field_to_ui_table_column()
|
||||||
|
for field in data.client_data.fields + ui_table_columns
|
||||||
|
if field.sortable
|
||||||
|
],
|
||||||
|
"ui_mock_data": [
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
field.name: field.field_mock_value(index=index)
|
||||||
|
for field in data.client_data.fields + ui_table_columns
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for index in range(1, 7)
|
||||||
|
],
|
||||||
|
"db_fields": [field.field_to_db() for field in data.client_data.fields],
|
||||||
|
"all_fields": [field.field_to_py() for field in data.client_data.fields],
|
||||||
|
},
|
||||||
|
"settings_data": {
|
||||||
|
"enabled": data.settings_data.enabled,
|
||||||
|
"is_admin_settings_only": data.settings_data.type == "admin",
|
||||||
|
"db_fields": [field.field_to_db() for field in data.settings_data.fields],
|
||||||
|
"all_fields": [field.field_to_py() for field in data.settings_data.fields],
|
||||||
|
},
|
||||||
|
"public_page": data.public_page,
|
||||||
|
"cancel_comment": remove_line_marker,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fields_to_html_input(
|
||||||
|
fields: list[DataField], model_name: str, ext_stub_dir: Path
|
||||||
|
) -> str:
|
||||||
|
template_path = Path(
|
||||||
|
ext_stub_dir, "templates", "extension_builder_stub", "_input_fields.html"
|
||||||
|
).as_posix()
|
||||||
|
|
||||||
|
rederer = _render_file(
|
||||||
|
template_path,
|
||||||
|
{
|
||||||
|
"fields": fields,
|
||||||
|
"model_name": model_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return rederer
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_lines_with_string(file_path: str, target: str) -> None:
|
||||||
|
"""
|
||||||
|
Removes lines from a file that contain the given target string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): Path to the file.
|
||||||
|
target (str): Substring to search for in lines to remove.
|
||||||
|
"""
|
||||||
|
with open(file_path, encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
filtered_lines = [line for line in lines if target not in line]
|
||||||
|
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.writelines(filtered_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
|
||||||
|
"""
|
||||||
|
Recursively renames files and directories by replacing part of their names.
|
||||||
|
"""
|
||||||
|
# First rename directories (bottom-up) so we don't lose paths while renaming
|
||||||
|
for root, dirs, files in os.walk(directory, topdown=False):
|
||||||
|
if _is_excluded_dir(root):
|
||||||
|
continue
|
||||||
|
# Rename files
|
||||||
|
for filename in files:
|
||||||
|
if old_text in filename:
|
||||||
|
old_path = os.path.join(root, filename)
|
||||||
|
new_filename = filename.replace(old_text, new_text)
|
||||||
|
new_path = os.path.join(root, new_filename)
|
||||||
|
try:
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
logger.trace(f"Renamed file: {old_path} -> {new_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to rename file {old_path}: {e}")
|
||||||
|
|
||||||
|
# Rename directories
|
||||||
|
for dirname in dirs:
|
||||||
|
if old_text in dirname:
|
||||||
|
old_dir_path = os.path.join(root, dirname)
|
||||||
|
new_dir_name = dirname.replace(old_text, new_text)
|
||||||
|
new_dir_path = os.path.join(root, new_dir_name)
|
||||||
|
try:
|
||||||
|
os.rename(old_dir_path, new_dir_path)
|
||||||
|
logger.trace(f"Renamed directory: {old_dir_path} -> {new_dir_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_excluded_dir(path):
|
||||||
|
for excluded_dir in excluded_dirs:
|
||||||
|
if path.startswith(excluded_dir):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from lnbits.settings import settings
|
|||||||
|
|
||||||
|
|
||||||
async def handle_fiat_payment_confirmation(
|
async def handle_fiat_payment_confirmation(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
||||||
@@ -29,7 +28,7 @@ async def handle_fiat_payment_confirmation(
|
|||||||
|
|
||||||
|
|
||||||
async def _credit_fiat_service_fee_wallet(
|
async def _credit_fiat_service_fee_wallet(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
fiat_provider_name = payment.fiat_provider
|
fiat_provider_name = payment.fiat_provider
|
||||||
if not fiat_provider_name:
|
if not fiat_provider_name:
|
||||||
@@ -66,7 +65,7 @@ async def _credit_fiat_service_fee_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def _debit_fiat_service_faucet_wallet(
|
async def _debit_fiat_service_faucet_wallet(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
fiat_provider_name = payment.fiat_provider
|
fiat_provider_name = payment.fiat_provider
|
||||||
if not fiat_provider_name:
|
if not fiat_provider_name:
|
||||||
@@ -129,8 +128,8 @@ async def handle_stripe_event(event: dict):
|
|||||||
|
|
||||||
def check_stripe_signature(
|
def check_stripe_signature(
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
sig_header: Optional[str],
|
sig_header: str | None,
|
||||||
secret: Optional[str],
|
secret: str | None,
|
||||||
tolerance_seconds=300,
|
tolerance_seconds=300,
|
||||||
):
|
):
|
||||||
if not sig_header:
|
if not sig_header:
|
||||||
|
|||||||
+124
-10
@@ -1,41 +1,95 @@
|
|||||||
|
from time import time
|
||||||
|
|
||||||
from lnurl import (
|
from lnurl import (
|
||||||
|
LnAddress,
|
||||||
|
Lnurl,
|
||||||
|
LnurlErrorResponse,
|
||||||
LnurlPayActionResponse,
|
LnurlPayActionResponse,
|
||||||
LnurlPayResponse,
|
LnurlPayResponse,
|
||||||
LnurlResponseException,
|
LnurlResponseException,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
execute_pay_request,
|
execute_pay_request,
|
||||||
|
execute_withdraw,
|
||||||
handle,
|
handle,
|
||||||
)
|
)
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.models import CreateLnurlPayment
|
from lnbits.core.crud import update_wallet
|
||||||
|
from lnbits.core.models import CreateLnurlPayment, Wallet
|
||||||
|
from lnbits.core.models.lnurl import StoredPayLink
|
||||||
|
from lnbits.helpers import check_callback_url
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
|
||||||
async def get_pr_from_lnurl(lnurl: str, amount_msat: int) -> str:
|
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
|
||||||
|
"""
|
||||||
|
Perform an LNURL withdraw to the given LNURL-withdraw link.
|
||||||
|
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
|
||||||
|
:param payment_request: The BOLT11 payment request to pay.
|
||||||
|
:raises LnurlResponseException: If the LNURL-withdraw process fails.
|
||||||
|
"""
|
||||||
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
||||||
|
if isinstance(res, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res.reason)
|
||||||
|
if not isinstance(res, LnurlWithdrawResponse):
|
||||||
|
raise LnurlResponseException("Invalid LNURL-withdraw response.")
|
||||||
|
try:
|
||||||
|
check_callback_url(res.callback)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
|
||||||
|
res2 = await execute_withdraw(
|
||||||
|
res, payment_request, user_agent=settings.user_agent, timeout=10
|
||||||
|
)
|
||||||
|
if isinstance(res2, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res2.reason)
|
||||||
|
if not isinstance(res2, LnurlSuccessResponse):
|
||||||
|
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pr_from_lnurl(
|
||||||
|
lnurl: str, amount_msat: int, comment: str | None = None
|
||||||
|
) -> str:
|
||||||
|
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
||||||
|
if isinstance(res, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res.reason)
|
||||||
if not isinstance(res, LnurlPayResponse):
|
if not isinstance(res, LnurlPayResponse):
|
||||||
raise LnurlResponseException(
|
raise LnurlResponseException(
|
||||||
"Invalid LNURL response. Expected LnurlPayResponse."
|
"Invalid LNURL response. Expected LnurlPayResponse."
|
||||||
)
|
)
|
||||||
res2 = await execute_pay_request(
|
res2 = await execute_pay_request(
|
||||||
res,
|
res,
|
||||||
msat=str(amount_msat),
|
msat=amount_msat,
|
||||||
|
comment=comment,
|
||||||
user_agent=settings.user_agent,
|
user_agent=settings.user_agent,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if not isinstance(res, LnurlPayActionResponse):
|
if isinstance(res2, LnurlErrorResponse):
|
||||||
raise LnurlResponseException(
|
raise LnurlResponseException(res2.reason)
|
||||||
"Invalid LNURL pay response. Expected LnurlPayActionResponse."
|
|
||||||
)
|
|
||||||
return res2.pr
|
return res2.pr
|
||||||
|
|
||||||
|
|
||||||
async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
|
async def fetch_lnurl_pay_request(
|
||||||
|
data: CreateLnurlPayment, wallet: Wallet | None = None
|
||||||
|
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
|
||||||
"""
|
"""
|
||||||
Pay an LNURL payment request.
|
Pay an LNURL payment request.
|
||||||
|
optional `wallet` is used to store the pay link in the wallet's stored links.
|
||||||
|
|
||||||
raises `LnurlResponseException` if pay request fails
|
raises `LnurlResponseException` if pay request fails
|
||||||
"""
|
"""
|
||||||
|
if not data.res and data.lnurl:
|
||||||
|
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
|
||||||
|
if isinstance(res, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res.reason)
|
||||||
|
if not isinstance(res, LnurlPayResponse):
|
||||||
|
raise LnurlResponseException(
|
||||||
|
"Invalid LNURL response. Expected LnurlPayResponse."
|
||||||
|
)
|
||||||
|
data.res = res
|
||||||
|
if not data.res:
|
||||||
|
raise LnurlResponseException("No LNURL pay request provided.")
|
||||||
|
|
||||||
if data.unit and data.unit != "sat":
|
if data.unit and data.unit != "sat":
|
||||||
# shift to float with 2 decimal places
|
# shift to float with 2 decimal places
|
||||||
@@ -45,9 +99,69 @@ async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionRes
|
|||||||
else:
|
else:
|
||||||
amount_msat = data.amount
|
amount_msat = data.amount
|
||||||
|
|
||||||
return await execute_pay_request(
|
res2 = await execute_pay_request(
|
||||||
data.res,
|
data.res,
|
||||||
msat=str(amount_msat),
|
msat=amount_msat,
|
||||||
|
comment=data.comment,
|
||||||
user_agent=settings.user_agent,
|
user_agent=settings.user_agent,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if wallet:
|
||||||
|
await store_paylink(data.res, res2, wallet, data.lnurl)
|
||||||
|
|
||||||
|
return data.res, res2
|
||||||
|
|
||||||
|
|
||||||
|
async def store_paylink(
|
||||||
|
res: LnurlPayResponse,
|
||||||
|
res2: LnurlPayActionResponse,
|
||||||
|
wallet: Wallet,
|
||||||
|
lnurl: LnAddress | Lnurl | None = None,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if res2.disposable is not False:
|
||||||
|
return # do not store disposable LNURL pay links
|
||||||
|
|
||||||
|
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
|
||||||
|
|
||||||
|
stored_paylink = None
|
||||||
|
# If we have only a LnurlPayResponse, we can use its lnaddress
|
||||||
|
# because the lnurl is not available.
|
||||||
|
if not lnurl:
|
||||||
|
for _data in res.metadata.list():
|
||||||
|
if _data[0] == "text/identifier":
|
||||||
|
stored_paylink = StoredPayLink(
|
||||||
|
lnurl=LnAddress(_data[1]), label=res.metadata.text
|
||||||
|
)
|
||||||
|
if not stored_paylink:
|
||||||
|
logger.warning(
|
||||||
|
"No lnaddress found in metadata for LNURL pay link. "
|
||||||
|
"Skipping storage."
|
||||||
|
)
|
||||||
|
return # skip if lnaddress not found in metadata
|
||||||
|
else:
|
||||||
|
if isinstance(lnurl, Lnurl):
|
||||||
|
_lnurl = str(lnurl.lud17 or lnurl.bech32)
|
||||||
|
else:
|
||||||
|
_lnurl = str(lnurl)
|
||||||
|
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
|
||||||
|
|
||||||
|
# update last_used if its already stored
|
||||||
|
for pl in wallet.stored_paylinks.links:
|
||||||
|
if pl.lnurl == stored_paylink.lnurl:
|
||||||
|
pl.last_used = int(time())
|
||||||
|
await update_wallet(wallet)
|
||||||
|
logger.debug(
|
||||||
|
"Updated last used time for LNURL "
|
||||||
|
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# if not already stored, append it
|
||||||
|
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
|
||||||
|
wallet.stored_paylinks.links.append(stored_paylink)
|
||||||
|
await update_wallet(wallet)
|
||||||
|
logger.debug(
|
||||||
|
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import smtplib
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -71,7 +70,7 @@ async def process_next_notification() -> None:
|
|||||||
|
|
||||||
async def send_admin_notification(
|
async def send_admin_notification(
|
||||||
message: str,
|
message: str,
|
||||||
message_type: Optional[str] = None,
|
message_type: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
return await send_notification(
|
return await send_notification(
|
||||||
settings.lnbits_telegram_notifications_chat_id,
|
settings.lnbits_telegram_notifications_chat_id,
|
||||||
@@ -85,7 +84,7 @@ async def send_admin_notification(
|
|||||||
async def send_user_notification(
|
async def send_user_notification(
|
||||||
user_notifications: UserNotifications,
|
user_notifications: UserNotifications,
|
||||||
message: str,
|
message: str,
|
||||||
message_type: Optional[str] = None,
|
message_type: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
email_address = (
|
email_address = (
|
||||||
@@ -110,7 +109,7 @@ async def send_notification(
|
|||||||
nostr_identifiers: list[str] | None,
|
nostr_identifiers: list[str] | None,
|
||||||
email_addresses: list[str] | None,
|
email_addresses: list[str] | None,
|
||||||
message: str,
|
message: str,
|
||||||
message_type: Optional[str] = None,
|
message_type: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
if telegram_chat_id and settings.is_telegram_notifications_configured():
|
if telegram_chat_id and settings.is_telegram_notifications_configured():
|
||||||
@@ -278,7 +277,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
|||||||
|
|
||||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
# TODO: websocket message should be a clean payment model
|
# TODO: websocket message should be a clean payment model
|
||||||
# await websocket_manager.send_data(payment.json(), wallet.inkey)
|
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||||
# TODO: figure out why we send the balance with the payment here.
|
# TODO: figure out why we send the balance with the payment here.
|
||||||
# cleaner would be to have a separate message for the balance
|
# cleaner would be to have a separate message for the balance
|
||||||
# and send it with the id of the wallet so wallets can subscribe to it
|
# and send it with the id of the wallet so wallets can subscribe to it
|
||||||
@@ -289,12 +288,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
|||||||
"payment": json.loads(payment.json()),
|
"payment": json.loads(payment.json()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await websocket_manager.send_data(payment_notification, wallet.inkey)
|
await websocket_manager.send(wallet.inkey, payment_notification)
|
||||||
await websocket_manager.send_data(payment_notification, wallet.adminkey)
|
await websocket_manager.send(wallet.adminkey, payment_notification)
|
||||||
|
await websocket_manager.send(
|
||||||
await websocket_manager.send_data(
|
|
||||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
|
||||||
payment.payment_hash,
|
payment.payment_hash,
|
||||||
|
json.dumps({"pending": payment.pending, "status": payment.status}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
@@ -58,11 +57,11 @@ async def pay_invoice(
|
|||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
payment_request: str,
|
payment_request: str,
|
||||||
max_sat: Optional[int] = None,
|
max_sat: int | None = None,
|
||||||
extra: Optional[dict] = None,
|
extra: dict | None = None,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
tag: str = "",
|
tag: str = "",
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
if settings.lnbits_only_allow_incoming_payments:
|
if settings.lnbits_only_allow_incoming_payments:
|
||||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||||
@@ -110,7 +109,7 @@ async def create_payment_request(
|
|||||||
|
|
||||||
|
|
||||||
async def create_fiat_invoice(
|
async def create_fiat_invoice(
|
||||||
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
|
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
fiat_provider_name = invoice_data.fiat_provider
|
fiat_provider_name = invoice_data.fiat_provider
|
||||||
if not fiat_provider_name:
|
if not fiat_provider_name:
|
||||||
@@ -140,7 +139,9 @@ async def create_fiat_invoice(
|
|||||||
payment_hash=internal_payment.payment_hash,
|
payment_hash=internal_payment.payment_hash,
|
||||||
currency=invoice_data.unit,
|
currency=invoice_data.unit,
|
||||||
memo=invoice_data.memo,
|
memo=invoice_data.memo,
|
||||||
|
extra=invoice_data.extra or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
if fiat_invoice.failed:
|
if fiat_invoice.failed:
|
||||||
logger.warning(fiat_invoice.error_message)
|
logger.warning(fiat_invoice.error_message)
|
||||||
internal_payment.status = PaymentState.FAILED
|
internal_payment.status = PaymentState.FAILED
|
||||||
@@ -231,16 +232,16 @@ async def create_invoice(
|
|||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
amount: float,
|
amount: float,
|
||||||
currency: Optional[str] = "sat",
|
currency: str | None = "sat",
|
||||||
memo: str,
|
memo: str,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: bytes | None = None,
|
||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: bytes | None = None,
|
||||||
expiry: Optional[int] = None,
|
expiry: int | None = None,
|
||||||
extra: Optional[dict] = None,
|
extra: dict | None = None,
|
||||||
webhook: Optional[str] = None,
|
webhook: str | None = None,
|
||||||
internal: Optional[bool] = False,
|
internal: bool | None = False,
|
||||||
payment_hash: str | None = None,
|
payment_hash: str | None = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
if not amount > 0:
|
if not amount > 0:
|
||||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||||
@@ -427,7 +428,7 @@ def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
|
|||||||
async def update_wallet_balance(
|
async def update_wallet_balance(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
amount: int,
|
amount: int,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
):
|
):
|
||||||
if amount == 0:
|
if amount == 0:
|
||||||
raise ValueError("Amount cannot be 0.")
|
raise ValueError("Amount cannot be 0.")
|
||||||
@@ -486,14 +487,14 @@ async def update_wallet_balance(
|
|||||||
|
|
||||||
|
|
||||||
async def check_wallet_limits(
|
async def check_wallet_limits(
|
||||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
await check_time_limit_between_transactions(wallet_id, conn)
|
await check_time_limit_between_transactions(wallet_id, conn)
|
||||||
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, 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(
|
||||||
wallet_id: str, conn: Optional[Connection] = None
|
wallet_id: str, conn: Connection | None = 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:
|
||||||
@@ -513,7 +514,7 @@ async def check_time_limit_between_transactions(
|
|||||||
|
|
||||||
|
|
||||||
async def check_wallet_daily_withdraw_limit(
|
async def check_wallet_daily_withdraw_limit(
|
||||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||||
if not limit:
|
if not limit:
|
||||||
@@ -546,8 +547,8 @@ async def check_wallet_daily_withdraw_limit(
|
|||||||
async def calculate_fiat_amounts(
|
async def calculate_fiat_amounts(
|
||||||
amount: float,
|
amount: float,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
currency: Optional[str] = None,
|
currency: str | None = None,
|
||||||
extra: Optional[dict] = None,
|
extra: dict | None = None,
|
||||||
) -> tuple[int, dict]:
|
) -> tuple[int, dict]:
|
||||||
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 {}
|
fiat_amounts: dict = extra or {}
|
||||||
@@ -582,9 +583,9 @@ async def calculate_fiat_amounts(
|
|||||||
|
|
||||||
|
|
||||||
async def check_transaction_status(
|
async def check_transaction_status(
|
||||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||||
) -> PaymentStatus:
|
) -> PaymentStatus:
|
||||||
payment: Optional[Payment] = await get_wallet_payment(
|
payment: Payment | None = await get_wallet_payment(
|
||||||
wallet_id, payment_hash, conn=conn
|
wallet_id, payment_hash, conn=conn
|
||||||
)
|
)
|
||||||
if not payment:
|
if not payment:
|
||||||
@@ -598,7 +599,7 @@ async def check_transaction_status(
|
|||||||
|
|
||||||
async def get_payments_daily_stats(
|
async def get_payments_daily_stats(
|
||||||
filters: Filters[PaymentFilters],
|
filters: Filters[PaymentFilters],
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
) -> list[PaymentDailyStats]:
|
) -> list[PaymentDailyStats]:
|
||||||
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
||||||
balance_total: float = 0
|
balance_total: float = 0
|
||||||
@@ -647,7 +648,7 @@ async def get_payments_daily_stats(
|
|||||||
async def _pay_invoice(
|
async def _pay_invoice(
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
create_payment_model: CreatePayment,
|
create_payment_model: CreatePayment,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
):
|
):
|
||||||
async with payment_lock:
|
async with payment_lock:
|
||||||
if wallet_id not in wallets_payments_lock:
|
if wallet_id not in wallets_payments_lock:
|
||||||
@@ -670,8 +671,8 @@ async def _pay_invoice(
|
|||||||
async def _pay_internal_invoice(
|
async def _pay_internal_invoice(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
create_payment_model: CreatePayment,
|
create_payment_model: CreatePayment,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Optional[Payment]:
|
) -> Payment | None:
|
||||||
"""
|
"""
|
||||||
Pay an internal payment.
|
Pay an internal payment.
|
||||||
returns None if the payment is not internal.
|
returns None if the payment is not internal.
|
||||||
@@ -738,7 +739,7 @@ async def _pay_internal_invoice(
|
|||||||
async def _pay_external_invoice(
|
async def _pay_external_invoice(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
create_payment_model: CreatePayment,
|
create_payment_model: CreatePayment,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
checking_id = create_payment_model.payment_hash
|
checking_id = create_payment_model.payment_hash
|
||||||
amount_msat = create_payment_model.amount_msat
|
amount_msat = create_payment_model.amount_msat
|
||||||
@@ -807,7 +808,7 @@ async def _pay_external_invoice(
|
|||||||
async def update_payment_success_status(
|
async def update_payment_success_status(
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
status: PaymentStatus,
|
status: PaymentStatus,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
if status.success:
|
if status.success:
|
||||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
service_fee_msat = service_fee(payment.amount, internal=False)
|
||||||
@@ -831,7 +832,7 @@ async def _fundingsource_pay_invoice(
|
|||||||
|
|
||||||
|
|
||||||
async def _verify_external_payment(
|
async def _verify_external_payment(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, conn: Connection | None = None
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# fail on pending payments
|
# fail on pending payments
|
||||||
if payment.pending:
|
if payment.pending:
|
||||||
@@ -862,7 +863,7 @@ async def _check_wallet_for_payment(
|
|||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
tag: str,
|
tag: str,
|
||||||
amount_msat: int,
|
amount_msat: int,
|
||||||
conn: Optional[Connection] = None,
|
conn: Connection | None = None,
|
||||||
):
|
):
|
||||||
wallet = await get_wallet(wallet_id, conn=conn)
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
if not wallet:
|
if not wallet:
|
||||||
@@ -878,7 +879,7 @@ async def _check_wallet_for_payment(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_payment_request(
|
def _validate_payment_request(
|
||||||
payment_request: str, max_sat: Optional[int] = None
|
payment_request: str, max_sat: int | None = None
|
||||||
) -> Bolt11:
|
) -> Bolt11:
|
||||||
try:
|
try:
|
||||||
invoice = bolt11_decode(payment_request)
|
invoice = bolt11_decode(payment_request)
|
||||||
@@ -901,7 +902,7 @@ def _validate_payment_request(
|
|||||||
|
|
||||||
|
|
||||||
async def _credit_service_fee_wallet(
|
async def _credit_service_fee_wallet(
|
||||||
wallet: Wallet, payment: Payment, conn: Optional[Connection] = None
|
wallet: Wallet, payment: Payment, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||||
@@ -927,7 +928,7 @@ async def _credit_service_fee_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def _check_fiat_invoice_limits(
|
async def _check_fiat_invoice_limits(
|
||||||
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
|
amount_sat: int, fiat_provider_name: str, conn: Connection | None = None
|
||||||
):
|
):
|
||||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||||
if not limits:
|
if not limits:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -37,7 +36,7 @@ from .settings import update_cached_settings
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_account(
|
async def create_user_account(
|
||||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
account: Account | None = None, wallet_name: str | None = 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.")
|
||||||
@@ -46,9 +45,9 @@ async def create_user_account(
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_account_no_ckeck(
|
async def create_user_account_no_ckeck(
|
||||||
account: Optional[Account] = None,
|
account: Account | None = None,
|
||||||
wallet_name: Optional[str] = None,
|
wallet_name: str | None = None,
|
||||||
default_exts: Optional[list[str]] = None,
|
default_exts: list[str] | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
@@ -165,12 +164,12 @@ async def check_admin_settings():
|
|||||||
settings.first_install = True
|
settings.first_install = True
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
||||||
"to get the superuser."
|
"to get the superuser."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
||||||
account = None
|
account = None
|
||||||
if super_user:
|
if super_user:
|
||||||
account = await get_account(super_user)
|
account = await get_account(super_user)
|
||||||
|
|||||||
@@ -1,27 +1,65 @@
|
|||||||
from fastapi import WebSocket
|
from asyncio import Queue
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebsocketConnection:
|
||||||
|
item_id: str
|
||||||
|
websocket: WebSocket
|
||||||
|
receive_queue: Queue[str]
|
||||||
|
|
||||||
|
|
||||||
class WebsocketConnectionManager:
|
class WebsocketConnectionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.active_connections: list[WebSocket] = []
|
self.active_connections: list[WebsocketConnection] = []
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, item_id: str):
|
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection:
|
||||||
logger.debug(f"Websocket connected to {item_id}")
|
logger.debug(f"Websocket connected to {item_id}")
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
self.active_connections.append(websocket)
|
conn = WebsocketConnection(
|
||||||
|
item_id=item_id,
|
||||||
|
websocket=websocket,
|
||||||
|
receive_queue=Queue(),
|
||||||
|
)
|
||||||
|
self.active_connections.append(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket):
|
async def listen(self, conn: WebsocketConnection) -> None:
|
||||||
self.active_connections.remove(websocket)
|
while settings.lnbits_running:
|
||||||
|
try:
|
||||||
|
data = await conn.websocket.receive_text()
|
||||||
|
logger.debug(f"WS received data from {conn.item_id}: {data}")
|
||||||
|
conn.receive_queue.put_nowait(data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
for _conn in self.active_connections:
|
||||||
|
if _conn.websocket == conn.websocket:
|
||||||
|
self.active_connections.remove(_conn)
|
||||||
|
logger.debug(f"WS disconnected from {conn.item_id}")
|
||||||
|
break # out of the listen and the fastapi route
|
||||||
|
|
||||||
async def send_data(self, message: str, item_id: str):
|
def get_connections(self, item_id: str) -> list[WebsocketConnection]:
|
||||||
for connection in self.active_connections:
|
conns = []
|
||||||
if connection.path_params["item_id"] == item_id:
|
for conn in self.active_connections:
|
||||||
await connection.send_text(message)
|
if conn.item_id == item_id:
|
||||||
|
conns.append(conn)
|
||||||
|
return conns
|
||||||
|
|
||||||
|
def has_connection(self, item_id: str) -> bool:
|
||||||
|
return len(self.get_connections(item_id)) > 0
|
||||||
|
|
||||||
|
async def send(self, item_id: str, data: str) -> None:
|
||||||
|
for conn in self.get_connections(item_id):
|
||||||
|
await conn.websocket.send_text(data)
|
||||||
|
|
||||||
|
|
||||||
websocket_manager = WebsocketConnectionManager()
|
websocket_manager = WebsocketConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
async def websocket_updater(item_id: str, data: str):
|
# deprecated import and use `websocket_manager.send()` instead
|
||||||
return await websocket_manager.send_data(data, item_id)
|
async def websocket_updater(item_id: str, data: str) -> None:
|
||||||
|
return await websocket_manager.send(item_id, data)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,27 @@
|
|||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
<span v-text="$t('misc_disable_extensions_builder')"></span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span
|
||||||
|
v-text="$t('misc_disable_extensions_builder_label')"
|
||||||
|
></span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
size="md"
|
||||||
|
v-model="formData.lnbits_extensions_builder_activate_non_admins"
|
||||||
|
checked-icon="check"
|
||||||
|
color="green"
|
||||||
|
unchecked-icon="clear"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
@@ -105,6 +126,17 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<p>
|
||||||
|
<span v-text="$t('extension_builder_manifest_url')"></span>
|
||||||
|
</p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.lnbits_extensions_builder_manifest_url"
|
||||||
|
:label="$t('extension_builder_manifest_url')"
|
||||||
|
:hint="$t('extension_builder_manifest_url_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -53,8 +53,7 @@
|
|||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="wallet.adminkey"
|
:value="wallet.adminkey"
|
||||||
:options="{ width: 250 }"
|
:show-buttons="false"
|
||||||
class="rounded-borders"
|
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</div>
|
</div>
|
||||||
</q-popup-proxy>
|
</q-popup-proxy>
|
||||||
@@ -86,8 +85,7 @@
|
|||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="wallet.inkey"
|
:value="wallet.inkey"
|
||||||
:options="{ width: 250 }"
|
:show-buttons="false"
|
||||||
class="rounded-borders"
|
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</div>
|
</div>
|
||||||
</q-popup-proxy>
|
</q-popup-proxy>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
v-text="$t('only_admins_can_install')"
|
v-text="$t('only_admins_can_install')"
|
||||||
></i>
|
></i>
|
||||||
<q-space></q-space>
|
<q-space></q-space>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
:label="$t('search_extensions')"
|
:label="$t('search_extensions')"
|
||||||
:dense="dense"
|
:dense="dense"
|
||||||
@@ -53,6 +54,18 @@
|
|||||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||||
></span>
|
></span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
|
{% if extension_builder_enabled %}
|
||||||
|
<q-btn flat no-caps icon="architecture" to="/extensions/builder"
|
||||||
|
><span v-text="$t('create_extension')"></span
|
||||||
|
></q-btn>
|
||||||
|
{% else %}
|
||||||
|
<q-btn disabled flat no-caps icon="architecture"
|
||||||
|
><span v-text="$t('create_extension')"></span>
|
||||||
|
<q-tooltip
|
||||||
|
v-text="$t('only_admins_can_create_extensions')"
|
||||||
|
></q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
{% endif %}
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="g.user.admin"
|
v-if="g.user.admin"
|
||||||
flat
|
flat
|
||||||
@@ -124,8 +137,40 @@
|
|||||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||||
v-text="extension.name"
|
v-text="extension.name"
|
||||||
></div>
|
></div>
|
||||||
<div>
|
<div style="justify-content: space-between; display: flex">
|
||||||
<lnbits-extension-rating :rating="0" />
|
<lnbits-extension-rating :rating="0" />
|
||||||
|
<q-btn-group size="xs" style="margin: 5px 0">
|
||||||
|
<q-btn
|
||||||
|
v-if="extension.hasFreeRelease"
|
||||||
|
color="green"
|
||||||
|
size="xs"
|
||||||
|
:label="$t('free')"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="$t('extension_has_free_release')"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="extension.hasPaidRelease || extension.paidFeatures"
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
:label="$t('paid')"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span
|
||||||
|
v-if="extension.hasPaidRelease"
|
||||||
|
v-text="$t('extension_has_paid_release')"
|
||||||
|
></span>
|
||||||
|
<br
|
||||||
|
v-if="extension.hasPaidRelease && extension.paidFeatures"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="extension.paidFeatures"
|
||||||
|
v-text="extension.paidFeatures"
|
||||||
|
></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-btn-group>
|
||||||
</div>
|
</div>
|
||||||
<div style="justify-content: space-between; display: flex">
|
<div style="justify-content: space-between; display: flex">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
@@ -328,13 +373,10 @@
|
|||||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div v-if="selectedRelease.paymentRequest">
|
<div v-if="selectedRelease.paymentRequest">
|
||||||
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
<lnbits-qrcode
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||||
<lnbits-qrcode
|
:href="'lightning:' + selectedRelease.paymentRequest"
|
||||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
></lnbits-qrcode>
|
||||||
></lnbits-qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||||
@@ -702,13 +744,10 @@
|
|||||||
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
|
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
||||||
<a
|
<lnbits-qrcode
|
||||||
|
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
||||||
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
||||||
>
|
></lnbits-qrcode>
|
||||||
<lnbits-qrcode
|
|
||||||
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="col">
|
<div v-else class="col">
|
||||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||||
@@ -909,7 +948,6 @@
|
|||||||
:href="selectedExtensionDetails.repo"
|
:href="selectedExtensionDetails.repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="q-pr-xs"
|
|
||||||
><q-tooltip>repository</q-tooltip></q-btn
|
><q-tooltip>repository</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||||
|
<!---->
|
||||||
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
|
<!---->
|
||||||
|
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-stepper
|
||||||
|
v-model="step"
|
||||||
|
ref="stepper"
|
||||||
|
color="primary"
|
||||||
|
animated
|
||||||
|
header-nav
|
||||||
|
class="q-pt-sm"
|
||||||
|
@update:model-value="onStepChange"
|
||||||
|
>
|
||||||
|
<q-step
|
||||||
|
:name="1"
|
||||||
|
title="Describe"
|
||||||
|
icon="info"
|
||||||
|
:done="step > 1"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<span class="text-h6">
|
||||||
|
Tell us something about your extension:
|
||||||
|
</span>
|
||||||
|
<ul>
|
||||||
|
<li>This is the first step, you can return and change it.</li>
|
||||||
|
<li>
|
||||||
|
The <code>`name`</code> and
|
||||||
|
<code>`sort description`</code> fields are what the users will
|
||||||
|
see when browsing the list of extensions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The <code>`id`</code> field is used internally and in the URL of
|
||||||
|
your extension.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- todo: add icon -->
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="Upload Existing config"
|
||||||
|
@click="$refs.extensionDataInput.click()"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="extensionDataInput"
|
||||||
|
accept="application/json"
|
||||||
|
style="display: none"
|
||||||
|
@change="onJsonDataInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-mt-sm"></q-separator>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.name"
|
||||||
|
label="Extension Name"
|
||||||
|
hint="The name of your extension"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.id"
|
||||||
|
label="Extension Id"
|
||||||
|
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.short_description"
|
||||||
|
label="Short Description"
|
||||||
|
hint="A short description that is shown in the extension list."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.description"
|
||||||
|
label="Description"
|
||||||
|
hint="A detailed description of your extension."
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="2"
|
||||||
|
title="Settings"
|
||||||
|
icon="settings"
|
||||||
|
:done="step > 2"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep2"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('settings')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.settings_data.enabled"
|
||||||
|
label="Generate Settings Fields"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Define what settings your extension will have.</li>
|
||||||
|
<li>
|
||||||
|
You can choose if each user has its own settings or if the
|
||||||
|
settings are global (set by the admin).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.settings_data.enabled"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-md-2 col-sm-12">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.settings_data.type"
|
||||||
|
:options="settingsTypes"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10 col-sm-12 q-pt-sm">
|
||||||
|
<q-badge
|
||||||
|
v-if="extensionData.settings_data.type === 'user'"
|
||||||
|
outline
|
||||||
|
class="text-caption q-ml-md"
|
||||||
|
>Each user can set its own settings for this extension.</q-badge
|
||||||
|
>
|
||||||
|
<q-badge v-else outline class="text-caption q-ml-md"
|
||||||
|
>Settings are set by the admin and apply to all users of the
|
||||||
|
extension</q-badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.settings_data.fields"
|
||||||
|
:hide-advanced="true"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="3"
|
||||||
|
:done="step > 3"
|
||||||
|
title="Owner Data"
|
||||||
|
icon="list"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep3"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('owner_data')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-input
|
||||||
|
v-model="extensionData.owner_data.name"
|
||||||
|
filled
|
||||||
|
label="Owner Table Name"
|
||||||
|
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
|
||||||
|
class="q-mb-xl"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The owner of the extension manages this data. It can add, remove
|
||||||
|
and update instances of it.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Some fileds are present by default, like
|
||||||
|
<code>created_at</code>, <code>updated_at</code> and
|
||||||
|
<code>extra</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.owner_data.fields"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="4"
|
||||||
|
:done="step > 4"
|
||||||
|
title="Client Data"
|
||||||
|
icon="blur_linear"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep4"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('client_data')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<!-- <q-toggle
|
||||||
|
v-model="extensionData.client_data.enabled"
|
||||||
|
label="Generate Client Table"
|
||||||
|
disable
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br /> -->
|
||||||
|
<q-input
|
||||||
|
v-if="extensionData.client_data.enabled"
|
||||||
|
v-model="extensionData.client_data.name"
|
||||||
|
filled
|
||||||
|
label="Client Table Name"
|
||||||
|
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
|
||||||
|
class="q-mb-xl"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
This data is created by users of the extension. Usually when
|
||||||
|
they submit a form or make a payment.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The owner of the extension can view this data, but should not
|
||||||
|
modify it.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.client_data.enabled"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.client_data.fields"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="5"
|
||||||
|
:done="step > 5"
|
||||||
|
title="Public Pages"
|
||||||
|
icon="link"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep5"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('public_page')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.has_public_page"
|
||||||
|
label="Generate Public Page"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Most extensions have a public page that can be shared (this page
|
||||||
|
will still be accessible even if you have restricted access to
|
||||||
|
your LNbits install).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="extensionData.public_page.has_public_page">
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page title</q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
>Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code>
|
||||||
|
(Owner Data) that will be used as a title for the public
|
||||||
|
page.</q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.owner_data_fields.name"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page description</q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
>Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code>
|
||||||
|
(Owner Data) that will be used as a description for the
|
||||||
|
public page.</q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.owner_data_fields.description"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page inputs</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the fields from the
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that will be shown as inputs in
|
||||||
|
the public page form.
|
||||||
|
</li>
|
||||||
|
<li>You can select multiple fields.</li>
|
||||||
|
<li>
|
||||||
|
A corresponding input field will be created for each
|
||||||
|
selected field.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
v-model="extensionData.public_page.client_data_fields.public_inputs"
|
||||||
|
:options="extensionData.client_data.fields.map(f => f.name)"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Generate Action Button</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
If enabled, the public page will have a button to
|
||||||
|
perform an action (e.g. generate a payment request).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The action will use the selected input fields from
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) as parameters.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A corresponding REST API endpoint will be created.
|
||||||
|
</li>
|
||||||
|
</ul></q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.action_fields.generate_action"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.public_page.action_fields.generate_action"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
|
||||||
|
<div v-if="extensionData.public_page.action_fields.generate_action">
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Generate Payment Logic</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
If enabled, the endpoint will create an invoice from
|
||||||
|
the submitted data and the UI will show the QR code
|
||||||
|
with the invoice.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A listener will be created to check for the pay event.
|
||||||
|
</li>
|
||||||
|
<li>You must map the fieds.</li>
|
||||||
|
</ul></q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.action_fields.generate_payment_logic"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
|
||||||
|
class="row q-col-gutter-md q-mt-md"
|
||||||
|
>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Wallet</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) that represents the wallet which
|
||||||
|
will generate the invoice and receive the payments.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Wallet</code> will be
|
||||||
|
shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.wallet_id"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Currency</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) that represents the currency
|
||||||
|
which will be used to for the amount.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Currency</code> will
|
||||||
|
be shown.
|
||||||
|
</li>
|
||||||
|
<li>Empty if you want to use sats.</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.currency"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Amount</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) or
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that represents the amount (in
|
||||||
|
the selected currency).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Integer</code> and
|
||||||
|
<code>Float</code> will be shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.amount_source"
|
||||||
|
:options="amountSource"
|
||||||
|
class="q-mr-sm"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.amount"
|
||||||
|
:options="paymentActionAmountFields"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Paid Flag</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that will be set to true when
|
||||||
|
the invoice is paid.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Boolean</code> will be
|
||||||
|
shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.paid_flag"
|
||||||
|
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="6"
|
||||||
|
:done="step > 6"
|
||||||
|
title="Publish"
|
||||||
|
icon="publish"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div v-if="g.user.admin" class="row">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.stub_version"
|
||||||
|
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
|
||||||
|
:options="extensionStubVersions.map(f => f.version)"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<q-btn
|
||||||
|
@click="cleanCacheData()"
|
||||||
|
color="grey"
|
||||||
|
outline
|
||||||
|
label="Clean Cache"
|
||||||
|
class="q-ml-md"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
The extension builder uses caching to speed up the build
|
||||||
|
process.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This action clears old data and redownloads the Extension
|
||||||
|
Builder Stub release.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="g.user.admin" class="row q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<q-btn
|
||||||
|
@click="buildExtensionAndDeploy()"
|
||||||
|
color="primary"
|
||||||
|
label="Build and Deploy (Admin Only)"
|
||||||
|
class="col"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
Installs the extension directly to this LNbits instance.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The extension will be enabled by default, and available to
|
||||||
|
all users.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<q-btn
|
||||||
|
@click="buildExtension()"
|
||||||
|
outline
|
||||||
|
color="gray"
|
||||||
|
label="Download Extension Zip"
|
||||||
|
icon="download"
|
||||||
|
class="col"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
Builds the extension and downloads a zip file with the code.
|
||||||
|
You can then install it manually in your LNbits instance.
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<template v-slot:navigation>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
|
||||||
|
<q-btn
|
||||||
|
v-if="step == 1"
|
||||||
|
label="Clear All Data"
|
||||||
|
color="negative"
|
||||||
|
@click="clearAllData"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
flat
|
||||||
|
color="grey-8"
|
||||||
|
class="q-mr-sm"
|
||||||
|
@click="previousStep()"
|
||||||
|
label="Back"
|
||||||
|
icon="chevron_left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
|
||||||
|
<q-stepper-navigation class="float-right">
|
||||||
|
<q-btn
|
||||||
|
v-if="step < 6"
|
||||||
|
@click="nextStep()"
|
||||||
|
color="primary"
|
||||||
|
label="Next"
|
||||||
|
></q-btn>
|
||||||
|
<template v-else>
|
||||||
|
<q-btn
|
||||||
|
@click="exportJsonData()"
|
||||||
|
color="primary"
|
||||||
|
label="Export JSON Data"
|
||||||
|
></q-btn>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
Exports the config JSON so it can be later imported or
|
||||||
|
shared.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This JSON is also added to the zip in a file called
|
||||||
|
`builder.json`.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
</q-stepper-navigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-stepper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md"></div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -209,16 +209,6 @@
|
|||||||
></payment-list>
|
></payment-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<div id="hiddenQrCodeContainer" style="display: none">
|
|
||||||
<lnbits-qrcode
|
|
||||||
v-if="receive.fiatPaymentReq"
|
|
||||||
:value="receive.fiatPaymentReq"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
<lnbits-qrcode
|
|
||||||
v-else
|
|
||||||
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if HIDE_API %}
|
{% if HIDE_API %}
|
||||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
@@ -277,21 +267,101 @@
|
|||||||
:label="$t('drain_funds')"
|
:label="$t('drain_funds')"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section>
|
||||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
<lnbits-qrcode
|
||||||
<lnbits-qrcode
|
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||||
:value="lightning:{{wallet.lnurlwithdraw_full}}"
|
href="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</a>
|
<p
|
||||||
<p v-text="$t('drain_funds_desc')"></p>
|
class="text-center"
|
||||||
|
v-text="$t('drain_funds_desc')"
|
||||||
|
></p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
group="extras"
|
||||||
|
icon="qr_code"
|
||||||
|
v-if="stored_paylinks.length > 0"
|
||||||
|
:label="$t('stored_paylinks')"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row flex" v-for="paylink in stored_paylinks">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
icon="send"
|
||||||
|
size="xs"
|
||||||
|
@click="sendToPaylink(paylink.lnurl)"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="`send to: ${paylink.lnurl}`"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
color="secondary"
|
||||||
|
icon="content_copy"
|
||||||
|
size="xs"
|
||||||
|
@click="copyText(paylink.lnurl)"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="`copy: ${paylink.lnurl}`"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<span
|
||||||
|
v-text="paylink.label"
|
||||||
|
class="q-mr-xs q-ml-xs"
|
||||||
|
></span>
|
||||||
|
<q-btn dense flat color="primary" icon="edit" size="xs">
|
||||||
|
<q-popup-edit
|
||||||
|
@update:model-value="editPaylink()"
|
||||||
|
v-model="paylink.label"
|
||||||
|
v-slot="scope"
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
dark
|
||||||
|
color="white"
|
||||||
|
v-model="scope.value"
|
||||||
|
dense
|
||||||
|
autofocus
|
||||||
|
counter
|
||||||
|
@keyup.enter="scope.set"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="edit" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-popup-edit>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="$t('edit')"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<span style="flex-grow: 1"></span>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
color="red"
|
||||||
|
icon="delete"
|
||||||
|
size="xs"
|
||||||
|
@click="deletePaylink(paylink.lnurl)"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="$t('delete')"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<span v-text="dateFromNow(paylink.last_used)"></span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-expansion-item
|
||||||
group="extras"
|
group="extras"
|
||||||
icon="phone_android"
|
icon="phone_android"
|
||||||
:label="$t('access_wallet_on_mobile')"
|
:label="$t('access_wallet_on_mobile')"
|
||||||
@@ -333,21 +403,15 @@
|
|||||||
:label="$t('export_to_phone')"
|
:label="$t('export_to_phone')"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section>
|
||||||
<p v-text="$t('export_to_phone_desc')"></p>
|
<p
|
||||||
|
class="text-center"
|
||||||
|
v-text="$t('export_to_phone_desc')"
|
||||||
|
></p>
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
|
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<span v-text="exportWalletQR"></span>
|
|
||||||
<q-card-actions class="flex-center q-pb-md">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
:label="$t('copy_wallet_url')"
|
|
||||||
@click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
|
|
||||||
></q-btn>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -784,18 +848,19 @@
|
|||||||
v-else-if="receive.paymentReq && receive.lnurl == null"
|
v-else-if="receive.paymentReq && receive.lnurl == null"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
>
|
>
|
||||||
<div class="text-center q-mb-lg">
|
<lnbits-qrcode
|
||||||
<a
|
v-if="receive.fiatPaymentReq"
|
||||||
v-if="receive.fiatPaymentReq"
|
:show-buttons="false"
|
||||||
:href="receive.fiatPaymentReq"
|
:href="receive.fiatPaymentReq"
|
||||||
target="_blank"
|
:value="receive.fiatPaymentReq"
|
||||||
>
|
>
|
||||||
<div v-html="invoiceQrCode"></div>
|
</lnbits-qrcode>
|
||||||
</a>
|
<lnbits-qrcode
|
||||||
<a v-else :href="'lightning:' + receive.paymentReq">
|
v-else
|
||||||
<div v-html="invoiceQrCode"></div>
|
:href="'lightning:' + receive.paymentReq"
|
||||||
</a>
|
:value="'lightning:' + receive.paymentReq"
|
||||||
</div>
|
>
|
||||||
|
</lnbits-qrcode>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h3 class="q-my-md">
|
<h3 class="q-my-md">
|
||||||
<span v-text="formattedAmount"></span>
|
<span v-text="formattedAmount"></span>
|
||||||
@@ -820,12 +885,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
|
|
||||||
:label="$t('copy_invoice')"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
flat
|
flat
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from shutil import make_archive, move
|
from shutil import make_archive, move
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import IO, Optional
|
from typing import IO
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import filetype
|
import filetype
|
||||||
@@ -71,10 +71,10 @@ async def api_test_email():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
|
@admin_router.get("/api/v1/settings")
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> Optional[AdminSettings]:
|
) -> AdminSettings | None:
|
||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ async def api_download_backup() -> FileResponse:
|
|||||||
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
|
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
|
||||||
is_pg = db_url and db_url.startswith("postgres://")
|
is_pg = db_url and db_url.startswith("postgres://")
|
||||||
|
|
||||||
if is_pg:
|
if is_pg and db_url:
|
||||||
p = urlparse(db_url)
|
p = urlparse(db_url)
|
||||||
command = (
|
command = (
|
||||||
f"pg_dump --host={p.hostname} "
|
f"pg_dump --host={p.hostname} "
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Callable, Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -238,7 +238,7 @@ async def api_delete_user_api_token(
|
|||||||
|
|
||||||
@auth_router.get("/{provider}", description="SSO Provider")
|
@auth_router.get("/{provider}", description="SSO Provider")
|
||||||
async def login_with_sso_provider(
|
async def login_with_sso_provider(
|
||||||
request: Request, provider: str, user_id: Optional[str] = None
|
request: Request, provider: str, user_id: str | None = None
|
||||||
):
|
):
|
||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
@@ -319,7 +319,7 @@ async def update_pubkey(
|
|||||||
data: UpdateUserPubkey,
|
data: UpdateUserPubkey,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) -> Optional[User]:
|
) -> User | None:
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise ValueError("Invalid user ID.")
|
raise ValueError("Invalid user ID.")
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ 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),
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) -> Optional[User]:
|
) -> User | None:
|
||||||
_validate_auth_timeout(payload.auth_time)
|
_validate_auth_timeout(payload.auth_time)
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise ValueError("Invalid user ID.")
|
raise ValueError("Invalid user ID.")
|
||||||
@@ -419,7 +419,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
|||||||
@auth_router.put("/update")
|
@auth_router.put("/update")
|
||||||
async def update(
|
async def update(
|
||||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||||
) -> Optional[User]:
|
) -> User | None:
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||||
if data.username and not is_valid_username(data.username):
|
if data.username and not is_valid_username(data.username):
|
||||||
@@ -461,7 +461,7 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
|||||||
return _auth_success_response(account.username, account.id, account.email)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = 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(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||||
@@ -490,9 +490,9 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
|
|||||||
|
|
||||||
|
|
||||||
def _auth_success_response(
|
def _auth_success_response(
|
||||||
username: Optional[str] = None,
|
username: str | None = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
email: Optional[str] = None,
|
email: str | None = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
payload = AccessTokenPayload(
|
payload = AccessTokenPayload(
|
||||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||||
@@ -533,7 +533,7 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _new_sso(provider: str) -> Optional[SSOBase]:
|
def _new_sso(provider: str) -> SSOBase | None:
|
||||||
try:
|
try:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
||||||
return None
|
return None
|
||||||
@@ -610,7 +610,7 @@ def _nostr_nip98_event(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _check_nostr_event_tags(event: dict):
|
def _check_nostr_event_tags(event: dict):
|
||||||
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
|
method: str | None = next((v for k, v in event["tags"] if k == "method"), None)
|
||||||
if not method:
|
if not method:
|
||||||
raise ValueError("Tag 'method' is missing.")
|
raise ValueError("Tag 'method' is missing.")
|
||||||
if not method.upper() == "POST":
|
if not method.upper() == "POST":
|
||||||
@@ -625,7 +625,7 @@ def _check_nostr_event_tags(event: dict):
|
|||||||
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
|
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
|
||||||
|
|
||||||
|
|
||||||
def _validate_auth_timeout(auth_time: Optional[int] = 0):
|
def _validate_auth_timeout(auth_time: int | None = 0):
|
||||||
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
|
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import traceback
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import (
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
APIRouter,
|
|
||||||
Depends,
|
|
||||||
HTTPException,
|
|
||||||
)
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extensions
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
@@ -65,9 +61,10 @@ async def api_install_extension(data: CreateExtension):
|
|||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
if not release:
|
||||||
raise HTTPException(
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
|
||||||
)
|
if not release.is_version_compatible:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
||||||
|
|
||||||
release.payment_hash = data.payment_hash
|
release.payment_hash = data.payment_hash
|
||||||
ext_meta = ExtensionMeta(installed_release=release)
|
ext_meta = ExtensionMeta(installed_release=release)
|
||||||
@@ -231,7 +228,7 @@ async def api_disable_extension(
|
|||||||
return SimpleStatus(
|
return SimpleStatus(
|
||||||
success=True, message=f"Extension '{ext_id}' already disabled."
|
success=True, message=f"Extension '{ext_id}' already disabled."
|
||||||
)
|
)
|
||||||
logger.info(f"Disabeling extension: {ext_id}.")
|
logger.info(f"Disabling extension: {ext_id}.")
|
||||||
user_ext.active = False
|
user_ext.active = False
|
||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from hashlib import sha256
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import (
|
||||||
|
SimpleStatus,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from lnbits.core.models.extensions import (
|
||||||
|
Extension,
|
||||||
|
ExtensionMeta,
|
||||||
|
InstallableExtension,
|
||||||
|
UserExtension,
|
||||||
|
)
|
||||||
|
from lnbits.core.models.extensions_builder import ExtensionData
|
||||||
|
from lnbits.core.services.extensions import (
|
||||||
|
activate_extension,
|
||||||
|
install_extension,
|
||||||
|
)
|
||||||
|
from lnbits.core.services.extensions_builder import (
|
||||||
|
build_extension_from_data,
|
||||||
|
clean_extension_builder_data,
|
||||||
|
zip_directory,
|
||||||
|
)
|
||||||
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
|
check_user_exists,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
create_user_extension,
|
||||||
|
get_user_extension,
|
||||||
|
update_user_extension,
|
||||||
|
)
|
||||||
|
|
||||||
|
extension_builder_router = APIRouter(
|
||||||
|
tags=["Extension Managment"],
|
||||||
|
prefix="/api/v1/extension/builder",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/zip",
|
||||||
|
summary="Build and download extension zip.",
|
||||||
|
description="""
|
||||||
|
This endpoint generates a zip file for the extension based on the provided data.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def api_build_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> FileResponse:
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
|
||||||
|
release, build_dir = await build_extension_from_data(data, stub_ext_id)
|
||||||
|
|
||||||
|
ext_info = InstallableExtension(
|
||||||
|
id=data.id,
|
||||||
|
name=data.name,
|
||||||
|
version="0.1.0",
|
||||||
|
short_description=data.short_description,
|
||||||
|
meta=ExtensionMeta(installed_release=release),
|
||||||
|
)
|
||||||
|
ext_zip_file = ext_info.zip_path
|
||||||
|
if ext_zip_file.is_file():
|
||||||
|
os.remove(ext_zip_file)
|
||||||
|
|
||||||
|
zip_directory(build_dir, ext_zip_file)
|
||||||
|
shutil.rmtree(build_dir, True)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
ext_zip_file, filename=f"{data.id}.zip", media_type="application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/deploy",
|
||||||
|
summary="Build extension based on provided config.",
|
||||||
|
description="""
|
||||||
|
This endpoint generates a zip file for the extension based on the provided data.
|
||||||
|
If `deploy` is set to true, the extension will be installed and activated.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def api_deploy_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
) -> SimpleStatus:
|
||||||
|
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||||
|
stub_ext_id = "extension_builder_stub"
|
||||||
|
release, build_dir = await build_extension_from_data(
|
||||||
|
data, stub_ext_id, working_dir_name
|
||||||
|
)
|
||||||
|
|
||||||
|
ext_info = InstallableExtension(
|
||||||
|
id=data.id,
|
||||||
|
name=data.name,
|
||||||
|
version="0.1.0",
|
||||||
|
short_description=data.short_description,
|
||||||
|
meta=ExtensionMeta(installed_release=release),
|
||||||
|
icon=release.icon,
|
||||||
|
)
|
||||||
|
ext_zip_file = ext_info.zip_path
|
||||||
|
if ext_zip_file.is_file():
|
||||||
|
os.remove(ext_zip_file)
|
||||||
|
|
||||||
|
zip_directory(build_dir.parent, ext_zip_file)
|
||||||
|
|
||||||
|
await install_extension(ext_info, skip_download=True)
|
||||||
|
|
||||||
|
await activate_extension(Extension.from_installable_ext(ext_info))
|
||||||
|
|
||||||
|
user_ext = await get_user_extension(user.id, data.id)
|
||||||
|
if not user_ext:
|
||||||
|
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
|
||||||
|
await create_user_extension(user_ext)
|
||||||
|
elif not user_ext.active:
|
||||||
|
user_ext.active = True
|
||||||
|
await update_user_extension(user_ext)
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message=f"Extension '{data.id}' deployed.")
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/preview",
|
||||||
|
summary="Build and preview the extension ui.",
|
||||||
|
)
|
||||||
|
async def api_preview_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> SimpleStatus:
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
stub_ext_id = "extension_builder_stub"
|
||||||
|
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||||
|
await build_extension_from_data(data, stub_ext_id, working_dir_name)
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.delete(
|
||||||
|
"",
|
||||||
|
summary="Clean extension builder data.",
|
||||||
|
description="""
|
||||||
|
This endpoint cleans the extension builder data.
|
||||||
|
""",
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def api_delete_extension_builder_data() -> SimpleStatus:
|
||||||
|
|
||||||
|
clean_extension_builder_data()
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message="Extension Builder data cleaned.")
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.core.services.fiat_providers import test_connection
|
from lnbits.core.services.fiat_providers import test_connection
|
||||||
from lnbits.decorators import check_admin
|
from lnbits.decorators import check_admin
|
||||||
|
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||||
|
|
||||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||||
|
|
||||||
@@ -16,3 +17,29 @@ fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
|||||||
)
|
)
|
||||||
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||||
return await test_connection(provider)
|
return await test_connection(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@fiat_router.post(
|
||||||
|
"/{provider}/connection_token",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def connection_token(provider: str):
|
||||||
|
provider_wallet = await get_fiat_provider(provider)
|
||||||
|
if provider == "stripe":
|
||||||
|
if not isinstance(provider_wallet, StripeWallet):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Stripe wallet/provider not configured"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tok = await provider_wallet.create_terminal_connection_token()
|
||||||
|
secret = tok.get("secret")
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail="Stripe returned no connection token"
|
||||||
|
)
|
||||||
|
return {"secret": secret}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to create connection token"
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from hashlib import sha256
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated, Optional, Union
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -7,7 +9,7 @@ from fastapi import Cookie, Depends, Query, Request
|
|||||||
from fastapi.exceptions import HTTPException
|
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 url_decode
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
@@ -124,6 +126,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
if ext.meta and ext.meta.latest_release
|
if ext.meta and ext.meta.latest_release
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||||
|
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||||
|
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||||
"installedRelease": (
|
"installedRelease": (
|
||||||
dict(ext.meta.installed_release)
|
dict(ext.meta.installed_release)
|
||||||
if ext.meta and ext.meta.installed_release
|
if ext.meta and ext.meta.installed_release
|
||||||
@@ -149,11 +154,95 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
"extension_data": extension_data,
|
"extension_data": extension_data,
|
||||||
|
"extension_builder_enabled": user.admin
|
||||||
|
or settings.lnbits_extensions_builder_activate_non_admins,
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get(
|
||||||
|
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
|
||||||
|
)
|
||||||
|
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
request,
|
||||||
|
"core/extensions_builder.html",
|
||||||
|
{
|
||||||
|
"user": user.json(),
|
||||||
|
"ajax": _is_ajax_request(request),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get(
|
||||||
|
"/extensions/builder/preview/{ext_id}",
|
||||||
|
name="extensions builder",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def extensions_builder_preview(
|
||||||
|
request: Request,
|
||||||
|
ext_id: str,
|
||||||
|
page_name: str | None = None,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
):
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||||
|
html_file_name = "index.html"
|
||||||
|
if page_name == "public_page":
|
||||||
|
html_file_name = "public_page.html"
|
||||||
|
|
||||||
|
html_file_path = Path(
|
||||||
|
"extension_builder_stub",
|
||||||
|
ext_id,
|
||||||
|
working_dir_name,
|
||||||
|
ext_id,
|
||||||
|
"templates",
|
||||||
|
ext_id,
|
||||||
|
html_file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
html_file_full_path = Path(
|
||||||
|
settings.extension_builder_working_dir_path, html_file_path
|
||||||
|
)
|
||||||
|
|
||||||
|
if not html_file_full_path.is_file():
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"err": f"Extension {ext_id} not found",
|
||||||
|
"message": "Please 'Refresh Preview' first.",
|
||||||
|
},
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = template_renderer().TemplateResponse(
|
||||||
|
request,
|
||||||
|
html_file_path.as_posix(),
|
||||||
|
{
|
||||||
|
"user": user.json(),
|
||||||
|
"ajax": _is_ajax_request(request),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
@@ -161,9 +250,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request,
|
request: Request,
|
||||||
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
|
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
wal: Optional[UUID4] = Query(None),
|
wal: UUID4 | None = Query(None),
|
||||||
):
|
):
|
||||||
if wal:
|
if wal:
|
||||||
wallet = await get_wallet(wal.hex)
|
wallet = await get_wallet(wal.hex)
|
||||||
@@ -456,7 +545,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
|
|||||||
if not settings.lnbits_allow_new_accounts:
|
if not settings.lnbits_allow_new_accounts:
|
||||||
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||||
|
|
||||||
lnurl = lnurl_decode(lightning)
|
lnurl = url_decode(lightning)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
check_callback_url(lnurl)
|
check_callback_url(lnurl)
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
)
|
)
|
||||||
from lnurl import (
|
from lnurl import LnurlResponseException
|
||||||
LnurlResponseException,
|
|
||||||
LnurlSuccessResponse,
|
|
||||||
)
|
|
||||||
from lnurl import execute_login as lnurlauth
|
from lnurl import execute_login as lnurlauth
|
||||||
from lnurl import execute_withdraw as lnurl_withdraw
|
|
||||||
from lnurl import handle as lnurl_handle
|
from lnurl import handle as lnurl_handle
|
||||||
from lnurl.models import (
|
from lnurl.models import (
|
||||||
LnurlAuthResponse,
|
LnurlAuthResponse,
|
||||||
@@ -22,8 +18,8 @@ from lnurl.models import (
|
|||||||
)
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.models import CreateLnurlWithdraw, Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.core.models.lnurl import CreateLnurlPayment
|
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
@@ -37,27 +33,46 @@ from ..services import fetch_lnurl_pay_request, pay_invoice
|
|||||||
lnurl_router = APIRouter(tags=["LNURL"])
|
lnurl_router = APIRouter(tags=["LNURL"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||||
|
try:
|
||||||
|
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
|
||||||
|
if isinstance(res, LnurlErrorResponse):
|
||||||
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
|
||||||
|
except LnurlResponseException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.get(
|
@lnurl_router.get(
|
||||||
"/api/v1/lnurlscan/{code}",
|
"/api/v1/lnurlscan/{code}",
|
||||||
dependencies=[Depends(require_invoice_key)],
|
dependencies=[Depends(require_invoice_key)],
|
||||||
|
deprecated=True,
|
||||||
response_model=LnurlPayResponse
|
response_model=LnurlPayResponse
|
||||||
| LnurlWithdrawResponse
|
| LnurlWithdrawResponse
|
||||||
| LnurlAuthResponse
|
| LnurlAuthResponse
|
||||||
| LnurlErrorResponse,
|
| LnurlErrorResponse,
|
||||||
)
|
)
|
||||||
async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
||||||
try:
|
res = await _handle(code)
|
||||||
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
|
if isinstance(res, LnurlPayResponse | LnurlWithdrawResponse | LnurlAuthResponse):
|
||||||
except LnurlResponseException as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
|
|
||||||
check_callback_url(res.callback)
|
check_callback_url(res.callback)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@lnurl_router.post(
|
||||||
|
"/api/v1/lnurlscan",
|
||||||
|
dependencies=[Depends(require_invoice_key)],
|
||||||
|
response_model=LnurlPayResponse
|
||||||
|
| LnurlWithdrawResponse
|
||||||
|
| LnurlAuthResponse
|
||||||
|
| LnurlErrorResponse,
|
||||||
|
)
|
||||||
|
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
|
||||||
|
return await _handle(scan.lnurl)
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post("/api/v1/lnurlauth")
|
@lnurl_router.post("/api/v1/lnurlauth")
|
||||||
async def api_perform_lnurlauth(
|
async def api_perform_lnurlauth(
|
||||||
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
|
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||||
@@ -82,16 +97,29 @@ async def api_perform_lnurlauth(
|
|||||||
async def api_payments_pay_lnurl(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
|
"""
|
||||||
|
Pay an LNURL payment request.
|
||||||
|
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
|
||||||
|
"""
|
||||||
|
if not data.res and not data.lnurl:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Missing LNURL or LnurlPayResponse data.",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = await fetch_lnurl_pay_request(data=data)
|
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
|
||||||
except LnurlResponseException as exc:
|
except LnurlResponseException as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
msg = f"Failed to connect to {data.res.callback}."
|
raise HTTPException(
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
extra: dict[str, Any] = {}
|
extra: dict[str, Any] = {}
|
||||||
if res.success_action:
|
if res2.disposable is False:
|
||||||
extra["success_action"] = res.success_action.json()
|
extra["stored"] = True
|
||||||
|
if res2.successAction:
|
||||||
|
extra["success_action"] = res2.successAction.json()
|
||||||
if data.comment:
|
if data.comment:
|
||||||
extra["comment"] = data.comment
|
extra["comment"] = data.comment
|
||||||
if data.unit and data.unit != "sat":
|
if data.unit and data.unit != "sat":
|
||||||
@@ -100,44 +128,9 @@ async def api_payments_pay_lnurl(
|
|||||||
|
|
||||||
payment = await pay_invoice(
|
payment = await pay_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=str(res.pr),
|
payment_request=str(res2.pr),
|
||||||
description=data.res.metadata.text,
|
description=res.metadata.text,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post(
|
|
||||||
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
|
|
||||||
)
|
|
||||||
async def api_payment_pay_with_nfc(
|
|
||||||
payment_request: str,
|
|
||||||
lnurl_data: CreateLnurlWithdraw,
|
|
||||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
|
||||||
try:
|
|
||||||
res = await lnurl_handle(
|
|
||||||
lnurl_data.lnurl_w.callback_url, user_agent=settings.user_agent, timeout=10
|
|
||||||
)
|
|
||||||
except (LnurlResponseException, Exception) as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return LnurlErrorResponse(reason=str(exc))
|
|
||||||
|
|
||||||
if not isinstance(res, LnurlWithdrawResponse):
|
|
||||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
|
||||||
try:
|
|
||||||
check_callback_url(res.callback)
|
|
||||||
except ValueError as exc:
|
|
||||||
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
res2 = await lnurl_withdraw(
|
|
||||||
res, payment_request, user_agent=settings.user_agent, timeout=10
|
|
||||||
)
|
|
||||||
except (LnurlResponseException, Exception) as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return LnurlErrorResponse(reason=str(exc))
|
|
||||||
if not isinstance(res2, (LnurlSuccessResponse, LnurlErrorResponse)):
|
|
||||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
|
||||||
|
|
||||||
return res2
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -89,14 +88,14 @@ async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeI
|
|||||||
@node_router.get("/info")
|
@node_router.get("/info")
|
||||||
async def api_get_info(
|
async def api_get_info(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[NodeInfoResponse]:
|
) -> NodeInfoResponse | None:
|
||||||
return await node.get_info()
|
return await node.get_info()
|
||||||
|
|
||||||
|
|
||||||
@node_router.get("/channels")
|
@node_router.get("/channels")
|
||||||
async def api_get_channels(
|
async def api_get_channels(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[list[NodeChannel]]:
|
) -> list[NodeChannel] | None:
|
||||||
return await node.get_channels()
|
return await node.get_channels()
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ async def api_get_channels(
|
|||||||
async def api_get_channel(
|
async def api_get_channel(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[NodeChannel]:
|
) -> NodeChannel | None:
|
||||||
return await node.get_channel(channel_id)
|
return await node.get_channel(channel_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,20 +112,20 @@ async def api_create_channel(
|
|||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
peer_id: str = Body(),
|
peer_id: str = Body(),
|
||||||
funding_amount: int = Body(),
|
funding_amount: int = Body(),
|
||||||
push_amount: Optional[int] = Body(None),
|
push_amount: int | None = Body(None),
|
||||||
fee_rate: Optional[int] = Body(None),
|
fee_rate: int | None = Body(None),
|
||||||
):
|
):
|
||||||
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||||
|
|
||||||
|
|
||||||
@super_node_router.delete("/channels")
|
@super_node_router.delete("/channels")
|
||||||
async def api_delete_channel(
|
async def api_delete_channel(
|
||||||
short_id: Optional[str],
|
short_id: str | None,
|
||||||
funding_txid: Optional[str],
|
funding_txid: str | None,
|
||||||
output_index: Optional[int],
|
output_index: int | None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[list[NodeChannel]]:
|
) -> list[NodeChannel] | None:
|
||||||
return await node.close_channel(
|
return await node.close_channel(
|
||||||
short_id,
|
short_id,
|
||||||
(
|
(
|
||||||
@@ -152,7 +151,7 @@ async def api_set_channel_fees(
|
|||||||
async def api_get_payments(
|
async def api_get_payments(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||||
) -> Optional[Page[NodePayment]]:
|
) -> Page[NodePayment] | None:
|
||||||
if not settings.lnbits_node_ui_transactions:
|
if not settings.lnbits_node_ui_transactions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_503_SERVICE_UNAVAILABLE,
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@@ -165,7 +164,7 @@ async def api_get_payments(
|
|||||||
async def api_get_invoices(
|
async def api_get_invoices(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||||
) -> Optional[Page[NodeInvoice]]:
|
) -> Page[NodeInvoice] | None:
|
||||||
if not settings.lnbits_node_ui_transactions:
|
if not settings.lnbits_node_ui_transactions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_503_SERVICE_UNAVAILABLE,
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@@ -192,25 +191,25 @@ async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
|||||||
|
|
||||||
|
|
||||||
class NodeRank(BaseModel):
|
class NodeRank(BaseModel):
|
||||||
capacity: Optional[int]
|
capacity: int | None
|
||||||
channelcount: Optional[int]
|
channelcount: int | None
|
||||||
age: Optional[int]
|
age: int | None
|
||||||
growth: Optional[int]
|
growth: int | None
|
||||||
availability: Optional[int]
|
availability: int | None
|
||||||
|
|
||||||
|
|
||||||
# Same for public and private api
|
# Same for public and private api
|
||||||
@node_router.get(
|
@node_router.get(
|
||||||
"/rank",
|
"/rank",
|
||||||
description="Retrieve node ranks from https://1ml.com",
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
response_model=Optional[NodeRank],
|
response_model=NodeRank | None,
|
||||||
)
|
)
|
||||||
@public_node_router.get(
|
@public_node_router.get(
|
||||||
"/rank",
|
"/rank",
|
||||||
description="Retrieve node ranks from https://1ml.com",
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
response_model=Optional[NodeRank],
|
response_model=NodeRank | None,
|
||||||
)
|
)
|
||||||
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> NodeRank | None:
|
||||||
node_id = await node.get_id()
|
node_id = await node.get_id()
|
||||||
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:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
@@ -10,7 +9,7 @@ from fastapi import (
|
|||||||
Query,
|
Query,
|
||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from lnurl import decode as lnurl_decode
|
from lnurl import url_decode
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.crud.payments import (
|
from lnbits.core.crud.payments import (
|
||||||
@@ -20,6 +19,7 @@ from lnbits.core.crud.payments import (
|
|||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CancelInvoice,
|
CancelInvoice,
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
|
CreateLnurlWithdraw,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
KeyType,
|
KeyType,
|
||||||
Payment,
|
Payment,
|
||||||
@@ -30,6 +30,7 @@ from lnbits.core.models import (
|
|||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
PaymentWalletStats,
|
PaymentWalletStats,
|
||||||
SettleInvoice,
|
SettleInvoice,
|
||||||
|
SimpleStatus,
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import User
|
from lnbits.core.models.users import User
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
@@ -60,6 +61,7 @@ from ..services import (
|
|||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
get_payments_daily_stats,
|
get_payments_daily_stats,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
|
perform_withdraw,
|
||||||
settle_hold_invoice,
|
settle_hold_invoice,
|
||||||
update_pending_payment,
|
update_pending_payment,
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
@@ -275,7 +277,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
|||||||
|
|
||||||
# TODO: refactor this route into a public and admin one
|
# TODO: refactor this route into a public and admin one
|
||||||
@payment_router.get("/{payment_hash}")
|
@payment_router.get("/{payment_hash}")
|
||||||
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
|
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
|
||||||
@@ -294,7 +296,9 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
|||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
|
|
||||||
if payment.failed:
|
if payment.failed:
|
||||||
return {"paid": False, "status": "failed", "details": payment}
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
|
return {"paid": False, "status": "failed", "details": payment}
|
||||||
|
return {"paid": False, "status": "failed"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
@@ -318,7 +322,7 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
|||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if payment_str[:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
url = str(lnurl_decode(payment_str))
|
url = str(url_decode(payment_str))
|
||||||
return JSONResponse({"domain": url})
|
return JSONResponse({"domain": url})
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(payment_str)
|
invoice = bolt11.decode(payment_str)
|
||||||
@@ -360,3 +364,23 @@ async def api_payments_cancel(
|
|||||||
detail="Payment does not exist or does not belong to this wallet.",
|
detail="Payment does not exist or does not belong to this wallet.",
|
||||||
)
|
)
|
||||||
return await cancel_hold_invoice(payment)
|
return await cancel_hold_invoice(payment)
|
||||||
|
|
||||||
|
|
||||||
|
@payment_router.post("/{payment_request}/pay-with-nfc")
|
||||||
|
async def api_payment_pay_with_nfc(
|
||||||
|
payment_request: str,
|
||||||
|
lnurl_data: CreateLnurlWithdraw,
|
||||||
|
) -> SimpleStatus:
|
||||||
|
if not lnurl_data.lnurl_w.lud17:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="LNURL-withdraw lud17 not provided.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await perform_withdraw(lnurl_data.lnurl_w.lud17, payment_request)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message="Payment sent with NFC.")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
@@ -48,6 +47,7 @@ from lnbits.helpers import (
|
|||||||
)
|
)
|
||||||
from lnbits.settings import EditableSettings, settings
|
from lnbits.settings import EditableSettings, settings
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
|
||||||
users_router = APIRouter(
|
users_router = APIRouter(
|
||||||
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
|
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
|
||||||
@@ -95,6 +95,9 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
|||||||
data.extra = data.extra or UserExtra()
|
data.extra = data.extra or UserExtra()
|
||||||
data.extra.provider = data.extra.provider or "lnbits"
|
data.extra.provider = data.extra.provider or "lnbits"
|
||||||
|
|
||||||
|
if data.pubkey:
|
||||||
|
data.pubkey = normalize_public_key(data.pubkey)
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
username=data.username,
|
username=data.username,
|
||||||
@@ -128,6 +131,9 @@ async def api_update_user(
|
|||||||
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if data.pubkey:
|
||||||
|
data.pubkey = normalize_public_key(data.pubkey)
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
id=user_id,
|
id=user_id,
|
||||||
username=data.username,
|
username=data.username,
|
||||||
@@ -223,7 +229,7 @@ async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
|
|||||||
|
|
||||||
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
||||||
async def api_users_create_user_wallet(
|
async def api_users_create_user_wallet(
|
||||||
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
|
user_id: str, name: str | None = Body(None), currency: str | None = Body(None)
|
||||||
):
|
):
|
||||||
if currency and currency not in allowed_currencies():
|
if currency and currency not in allowed_currencies():
|
||||||
raise ValueError(f"Currency '{currency}' not allowed.")
|
raise ValueError(f"Currency '{currency}' not allowed.")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -10,11 +9,11 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
|
||||||
|
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
|
||||||
from lnbits.core.models.wallets import WalletsFilters
|
from lnbits.core.models.wallets import WalletsFilters
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
@@ -93,13 +92,28 @@ async def api_reset_wallet_keys(
|
|||||||
return wallet
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
@wallet_router.put("/stored_paylinks/{wallet_id}")
|
||||||
|
async def api_put_stored_paylinks(
|
||||||
|
wallet_id: str,
|
||||||
|
data: StoredPayLinks,
|
||||||
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> list[StoredPayLink]:
|
||||||
|
if key_info.wallet.id != wallet_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
|
||||||
|
)
|
||||||
|
key_info.wallet.stored_paylinks.links = data.links
|
||||||
|
wallet = await update_wallet(key_info.wallet)
|
||||||
|
return wallet.stored_paylinks.links
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.patch("")
|
@wallet_router.patch("")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
name: Optional[str] = Body(None),
|
name: str | None = Body(None),
|
||||||
icon: Optional[str] = Body(None),
|
icon: str | None = Body(None),
|
||||||
color: Optional[str] = Body(None),
|
color: str | None = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: str | None = Body(None),
|
||||||
pinned: Optional[bool] = Body(None),
|
pinned: bool | None = Body(None),
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
from fastapi import (
|
from fastapi import APIRouter, WebSocket
|
||||||
APIRouter,
|
|
||||||
WebSocket,
|
|
||||||
WebSocketDisconnect,
|
|
||||||
)
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from ..services import websocket_manager
|
||||||
|
|
||||||
from ..services import (
|
|
||||||
websocket_manager,
|
|
||||||
websocket_updater,
|
|
||||||
)
|
|
||||||
|
|
||||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||||
|
|
||||||
|
|
||||||
@websocket_router.websocket("/{item_id}")
|
@websocket_router.websocket("/{item_id}")
|
||||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
|
||||||
await websocket_manager.connect(websocket, item_id)
|
conn = await websocket_manager.connect(item_id, websocket)
|
||||||
try:
|
await websocket_manager.listen(conn)
|
||||||
while settings.lnbits_running:
|
|
||||||
await websocket.receive_text()
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
websocket_manager.disconnect(websocket)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_router.post("/{item_id}")
|
@websocket_router.post("/{item_id}")
|
||||||
async def websocket_update_post(item_id: str, data: str):
|
async def websocket_update_post(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocket_updater(item_id, data)
|
await websocket_manager.send(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
@@ -36,7 +23,7 @@ async def websocket_update_post(item_id: str, data: str):
|
|||||||
@websocket_router.get("/{item_id}/{data}")
|
@websocket_router.get("/{item_id}/{data}")
|
||||||
async def websocket_update_get(item_id: str, data: str):
|
async def websocket_update_get(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocket_updater(item_id, data)
|
await websocket_manager.send(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|||||||
+9
-5
@@ -281,15 +281,19 @@ class Database(Compat):
|
|||||||
self.schema = self.name
|
self.schema = self.name
|
||||||
self.type = DB_TYPE
|
self.type = DB_TYPE
|
||||||
|
|
||||||
if DB_TYPE == SQLITE:
|
if self.type == POSTGRES and settings.lnbits_database_url:
|
||||||
|
database_uri = settings.lnbits_database_url.replace(
|
||||||
|
"postgres://", "postgresql+asyncpg://"
|
||||||
|
)
|
||||||
|
elif self.type == COCKROACH and settings.lnbits_database_url:
|
||||||
|
database_uri = settings.lnbits_database_url.replace(
|
||||||
|
"cockroachdb://", "cockroachdb+asyncpg://"
|
||||||
|
)
|
||||||
|
else:
|
||||||
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+aiosqlite:///{self.path}"
|
||||||
else:
|
|
||||||
database_uri = settings.lnbits_database_url.replace(
|
|
||||||
"postgres://", "postgresql+asyncpg://"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.name.startswith("ext_"):
|
if self.name.startswith("ext_"):
|
||||||
self.schema = self.name[4:]
|
self.schema = self.name[4:]
|
||||||
|
|||||||
+21
-21
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated, Literal, Optional, Union
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Cookie, Depends, Query, Request, Security
|
from fastapi import Cookie, Depends, Query, Request, Security
|
||||||
@@ -55,8 +55,8 @@ api_key_query = APIKeyQuery(
|
|||||||
class KeyChecker(SecurityBase):
|
class KeyChecker(SecurityBase):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: Optional[str] = None,
|
api_key: str | None = None,
|
||||||
expected_key_type: Optional[KeyType] = None,
|
expected_key_type: KeyType | None = None,
|
||||||
):
|
):
|
||||||
self.auto_error: bool = True
|
self.auto_error: bool = True
|
||||||
self.expected_key_type = expected_key_type
|
self.expected_key_type = expected_key_type
|
||||||
@@ -137,17 +137,17 @@ async def require_invoice_key(
|
|||||||
|
|
||||||
|
|
||||||
async def check_access_token(
|
async def check_access_token(
|
||||||
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
|
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
|
||||||
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
|
cookie_access_token: Annotated[str | None, Cookie()] = None,
|
||||||
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
|
bearer_access_token: Annotated[str | None, Depends(http_bearer)] = None,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
return header_access_token or cookie_access_token or bearer_access_token
|
return header_access_token or cookie_access_token or bearer_access_token
|
||||||
|
|
||||||
|
|
||||||
async def check_user_exists(
|
async def check_user_exists(
|
||||||
r: Request,
|
r: Request,
|
||||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||||
usr: Optional[UUID4] = None,
|
usr: UUID4 | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
if access_token:
|
if access_token:
|
||||||
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||||
@@ -176,9 +176,9 @@ async def check_user_exists(
|
|||||||
|
|
||||||
async def optional_user_id(
|
async def optional_user_id(
|
||||||
r: Request,
|
r: Request,
|
||||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||||
usr: Optional[UUID4] = None,
|
usr: UUID4 | None = None,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
return usr.hex
|
return usr.hex
|
||||||
if access_token:
|
if access_token:
|
||||||
@@ -189,7 +189,7 @@ async def optional_user_id(
|
|||||||
|
|
||||||
|
|
||||||
async def access_token_payload(
|
async def access_token_payload(
|
||||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||||
) -> AccessTokenPayload:
|
) -> AccessTokenPayload:
|
||||||
if not access_token:
|
if not access_token:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
|
||||||
@@ -231,11 +231,11 @@ def parse_filters(model: type[TFilterModel]):
|
|||||||
|
|
||||||
def dependency(
|
def dependency(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
offset: Optional[int] = None,
|
offset: int | None = None,
|
||||||
sortby: Optional[str] = None,
|
sortby: str | None = None,
|
||||||
direction: Optional[Literal["asc", "desc"]] = None,
|
direction: Literal["asc", "desc"] | None = None,
|
||||||
search: Optional[str] = Query(None, description="Text based search"),
|
search: str | None = Query(None, description="Text based search"),
|
||||||
):
|
):
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
filters = []
|
filters = []
|
||||||
@@ -259,7 +259,7 @@ def parse_filters(model: type[TFilterModel]):
|
|||||||
|
|
||||||
|
|
||||||
async def check_user_extension_access(
|
async def check_user_extension_access(
|
||||||
user_id: str, ext_id: str, conn: Optional[Connection] = None
|
user_id: str, ext_id: str, conn: Connection | None = None
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
"""
|
"""
|
||||||
Check if the user has access to a particular extension.
|
Check if the user has access to a particular extension.
|
||||||
@@ -292,7 +292,7 @@ async def _check_user_extension_access(user_id: str, path: str):
|
|||||||
|
|
||||||
async def _get_account_from_token(
|
async def _get_account_from_token(
|
||||||
access_token: str, path: str, method: str
|
access_token: str, path: str, method: str
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
try:
|
try:
|
||||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
return await _get_account_from_jwt_payload(
|
return await _get_account_from_jwt_payload(
|
||||||
@@ -310,7 +310,7 @@ async def _get_account_from_token(
|
|||||||
|
|
||||||
async def _get_account_from_jwt_payload(
|
async def _get_account_from_jwt_payload(
|
||||||
payload: AccessTokenPayload, path: str, method: str
|
payload: AccessTokenPayload, path: str, method: str
|
||||||
) -> Optional[Account]:
|
) -> Account | None:
|
||||||
account = None
|
account = None
|
||||||
if payload.sub:
|
if payload.sub:
|
||||||
account = await get_account_by_username(payload.sub)
|
account = await get_account_by_username(payload.sub)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
@@ -30,7 +29,7 @@ class UnsupportedError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
def render_html_error(request: Request, exc: Exception) -> Response | None:
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import AsyncGenerator, Coroutine
|
from collections.abc import AsyncGenerator, Coroutine
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
@@ -106,6 +106,7 @@ class FiatProvider(ABC):
|
|||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
currency: str,
|
currency: str,
|
||||||
memo: str | None = None,
|
memo: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+225
-65
@@ -2,11 +2,12 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Any, Literal
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
from lnbits.helpers import normalize_endpoint
|
from lnbits.helpers import normalize_endpoint
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -22,6 +23,34 @@ from .base import (
|
|||||||
FiatStatusResponse,
|
FiatStatusResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FiatMethod = Literal["checkout", "terminal"]
|
||||||
|
|
||||||
|
|
||||||
|
class StripeTerminalOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
capture_method: Literal["automatic", "manual"] = "automatic"
|
||||||
|
metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCheckoutOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
success_url: str | None = None
|
||||||
|
metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
line_item_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCreateInvoiceOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
fiat_method: FiatMethod = "checkout"
|
||||||
|
terminal: StripeTerminalOptions | None = None
|
||||||
|
checkout: StripeCheckoutOptions | None = None
|
||||||
|
|
||||||
|
|
||||||
class StripeWallet(FiatProvider):
|
class StripeWallet(FiatProvider):
|
||||||
"""https://docs.stripe.com/api"""
|
"""https://docs.stripe.com/api"""
|
||||||
@@ -31,9 +60,9 @@ class StripeWallet(FiatProvider):
|
|||||||
self._settings_fields = self._settings_connection_fields()
|
self._settings_fields = self._settings_connection_fields()
|
||||||
if not settings.stripe_api_endpoint:
|
if not settings.stripe_api_endpoint:
|
||||||
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
||||||
|
|
||||||
if not settings.stripe_api_secret_key:
|
if not settings.stripe_api_secret_key:
|
||||||
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
||||||
|
|
||||||
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
||||||
@@ -49,7 +78,7 @@ class StripeWallet(FiatProvider):
|
|||||||
logger.warning(f"Error closing stripe wallet connection: {e}")
|
logger.warning(f"Error closing stripe wallet connection: {e}")
|
||||||
|
|
||||||
async def status(
|
async def status(
|
||||||
self, only_check_settings: Optional[bool] = False
|
self, only_check_settings: bool | None = False
|
||||||
) -> FiatStatusResponse:
|
) -> FiatStatusResponse:
|
||||||
if only_check_settings:
|
if only_check_settings:
|
||||||
if self._settings_fields != self._settings_connection_fields():
|
if self._settings_fields != self._settings_connection_fields():
|
||||||
@@ -61,8 +90,10 @@ class StripeWallet(FiatProvider):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
||||||
available_balance = data.get("available", [{}])[0].get("amount", 0)
|
available = data.get("available") or []
|
||||||
# pending_balance = data.get("pending", {}).get("amount", 0)
|
available_balance = 0
|
||||||
|
if available and isinstance(available, list):
|
||||||
|
available_balance = int(available[0].get("amount", 0))
|
||||||
|
|
||||||
return FiatStatusResponse(balance=available_balance)
|
return FiatStatusResponse(balance=available_balance)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -76,82 +107,48 @@ class StripeWallet(FiatProvider):
|
|||||||
amount: float,
|
amount: float,
|
||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
currency: str,
|
currency: str,
|
||||||
memo: Optional[str] = None,
|
memo: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FiatInvoiceResponse:
|
) -> FiatInvoiceResponse:
|
||||||
amount_cents = int(amount * 100)
|
amount_cents = int(amount * 100)
|
||||||
form_data = [
|
opts = self._parse_create_opts(extra or {})
|
||||||
("mode", "payment"),
|
if not opts:
|
||||||
(
|
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
|
||||||
"success_url",
|
|
||||||
settings.stripe_payment_success_url or "https://lnbits.com",
|
|
||||||
),
|
|
||||||
("metadata[payment_hash]", payment_hash),
|
|
||||||
("line_items[0][price_data][currency]", currency.lower()),
|
|
||||||
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
|
|
||||||
("line_items[0][price_data][unit_amount]", amount_cents),
|
|
||||||
("line_items[0][quantity]", "1"),
|
|
||||||
]
|
|
||||||
encoded_data = urlencode(form_data)
|
|
||||||
|
|
||||||
try:
|
if opts.fiat_method == "checkout":
|
||||||
headers = self.headers.copy()
|
return await self._create_checkout_invoice(
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
amount_cents, currency, payment_hash, memo, opts
|
||||||
r = await self.client.post(
|
)
|
||||||
url="/v1/checkout/sessions", headers=headers, content=encoded_data
|
if opts.fiat_method == "terminal":
|
||||||
|
return await self._create_terminal_invoice(
|
||||||
|
amount_cents, currency, payment_hash, opts
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
session_id = data.get("id")
|
return FiatInvoiceResponse(
|
||||||
if not session_id:
|
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
|
||||||
return FiatInvoiceResponse(
|
)
|
||||||
ok=False, error_message="Server error: 'missing session id'"
|
|
||||||
)
|
|
||||||
payment_request = data.get("url")
|
|
||||||
if not payment_request:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'missing payment URL'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=True, checking_id=session_id, payment_request=payment_request
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'invalid json response'"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||||
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
try:
|
try:
|
||||||
r = await self.client.get(
|
stripe_id = self._normalize_stripe_id(checking_id)
|
||||||
url=f"/v1/checkout/sessions/{checking_id}",
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
data = r.json()
|
if stripe_id.startswith("cs_"):
|
||||||
payment_status = data.get("payment_status")
|
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
|
||||||
if not payment_status:
|
r.raise_for_status()
|
||||||
return FiatPaymentPendingStatus()
|
return self._status_from_checkout_session(r.json())
|
||||||
if payment_status == "paid":
|
|
||||||
# todo: handle fee
|
|
||||||
return FiatPaymentSuccessStatus()
|
|
||||||
|
|
||||||
expires_at = data.get("expires_at")
|
if stripe_id.startswith("pi_"):
|
||||||
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
|
||||||
if expires_at and expires_at < _24_hours_ago.timestamp():
|
r.raise_for_status()
|
||||||
# be defensive: add a 24 hour buffer
|
return self._status_from_payment_intent(r.json())
|
||||||
return FiatPaymentFailedStatus()
|
|
||||||
|
|
||||||
|
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"Error getting invoice status: {exc}")
|
logger.debug(f"Error getting invoice status: {exc}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
@@ -168,6 +165,169 @@ class StripeWallet(FiatProvider):
|
|||||||
value = await mock_queue.get()
|
value = await mock_queue.get()
|
||||||
yield value
|
yield value
|
||||||
|
|
||||||
|
async def create_terminal_connection_token(self) -> dict:
|
||||||
|
r = await self.client.post("/v1/terminal/connection_tokens")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def _create_checkout_invoice(
|
||||||
|
self,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
payment_hash: str,
|
||||||
|
memo: str | None,
|
||||||
|
opts: StripeCreateInvoiceOptions,
|
||||||
|
) -> FiatInvoiceResponse:
|
||||||
|
co = opts.checkout or StripeCheckoutOptions()
|
||||||
|
success_url = (
|
||||||
|
co.success_url
|
||||||
|
or settings.stripe_payment_success_url
|
||||||
|
or "https://lnbits.com"
|
||||||
|
)
|
||||||
|
line_item_name = co.line_item_name or memo or "LNbits Invoice"
|
||||||
|
|
||||||
|
form_data: list[tuple[str, str]] = [
|
||||||
|
("mode", "payment"),
|
||||||
|
("success_url", success_url),
|
||||||
|
("metadata[payment_hash]", payment_hash),
|
||||||
|
("line_items[0][price_data][currency]", currency.lower()),
|
||||||
|
("line_items[0][price_data][product_data][name]", line_item_name),
|
||||||
|
("line_items[0][price_data][unit_amount]", str(amount_cents)),
|
||||||
|
("line_items[0][quantity]", "1"),
|
||||||
|
]
|
||||||
|
form_data += self._encode_metadata("metadata", co.metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self.client.post(
|
||||||
|
"/v1/checkout/sessions",
|
||||||
|
headers=self._build_headers_form(),
|
||||||
|
content=urlencode(form_data),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
session_id, url = data.get("id"), data.get("url")
|
||||||
|
if not session_id or not url:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: missing id or url"
|
||||||
|
)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=True, checking_id=session_id, payment_request=url
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: invalid json response"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _create_terminal_invoice(
|
||||||
|
self,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
payment_hash: str,
|
||||||
|
opts: StripeCreateInvoiceOptions,
|
||||||
|
) -> FiatInvoiceResponse:
|
||||||
|
term = opts.terminal or StripeTerminalOptions()
|
||||||
|
data: dict[str, str] = {
|
||||||
|
"amount": str(amount_cents),
|
||||||
|
"currency": currency.lower(),
|
||||||
|
"payment_method_types[]": "card_present",
|
||||||
|
"capture_method": term.capture_method,
|
||||||
|
"metadata[payment_hash]": payment_hash,
|
||||||
|
"metadata[source]": "lnbits",
|
||||||
|
}
|
||||||
|
for k, v in (term.metadata or {}).items():
|
||||||
|
data[f"metadata[{k}]"] = str(v)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self.client.post("/v1/payment_intents", data=data)
|
||||||
|
r.raise_for_status()
|
||||||
|
pi = r.json()
|
||||||
|
pi_id, client_secret = pi.get("id"), pi.get("client_secret")
|
||||||
|
if not pi_id or not client_secret:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False,
|
||||||
|
error_message="Error: missing PaymentIntent or client_secret",
|
||||||
|
)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=True, checking_id=pi_id, payment_request=client_secret
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Error: invalid json response"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_stripe_id(self, checking_id: str) -> str:
|
||||||
|
"""Remove our internal prefix so Stripe sees a real id."""
|
||||||
|
return (
|
||||||
|
checking_id.replace("fiat_stripe_", "", 1)
|
||||||
|
if checking_id.startswith("fiat_stripe_")
|
||||||
|
else checking_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
|
||||||
|
"""Map a Checkout Session to LNbits fiat status."""
|
||||||
|
if data.get("payment_status") == "paid":
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
|
||||||
|
# Consider an expired session a fail (existing 24h rule).
|
||||||
|
expires_at = data.get("expires_at")
|
||||||
|
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
if expires_at and float(expires_at) < _24h_ago.timestamp():
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
|
||||||
|
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
|
||||||
|
status = pi.get("status")
|
||||||
|
|
||||||
|
if status == "succeeded":
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
|
||||||
|
if status in ("canceled", "payment_failed"):
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
if status == "requires_payment_method":
|
||||||
|
if pi.get("last_payment_error"):
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
now_ts = datetime.now(timezone.utc).timestamp()
|
||||||
|
created_ts = float(pi.get("created") or now_ts)
|
||||||
|
is_stale = (now_ts - created_ts) > 300
|
||||||
|
if is_stale:
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
def _build_headers_form(self) -> dict[str, str]:
|
||||||
|
return {**self.headers, "Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
|
||||||
|
def _encode_metadata(
|
||||||
|
self, prefix: str, md: dict[str, Any]
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for k, v in (md or {}).items():
|
||||||
|
out.append((f"{prefix}[{k}]", str(v)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _parse_create_opts(
|
||||||
|
self, raw_opts: dict[str, Any]
|
||||||
|
) -> StripeCreateInvoiceOptions | None:
|
||||||
|
try:
|
||||||
|
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(f"Invalid Stripe options: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _settings_connection_fields(self) -> str:
|
def _settings_connection_fields(self) -> str:
|
||||||
return "-".join(
|
return "-".join(
|
||||||
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
||||||
|
|||||||
+40
-19
@@ -3,7 +3,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
from urllib import request
|
from urllib import request
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ def urlsafe_short_hash() -> str:
|
|||||||
return shortuuid.uuid()
|
return shortuuid.uuid()
|
||||||
|
|
||||||
|
|
||||||
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str:
|
||||||
base = f"http://{settings.host}:{settings.port}" if external else ""
|
base = f"http://{settings.host}:{settings.port}" if external else ""
|
||||||
url_params = "?"
|
url_params = "?"
|
||||||
for key, value in params.items():
|
for key, value in params.items():
|
||||||
@@ -52,8 +52,13 @@ 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: list | None = None) -> Jinja2Templates:
|
||||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
folders = [
|
||||||
|
"lnbits/templates",
|
||||||
|
"lnbits/core/templates",
|
||||||
|
settings.extension_builder_working_dir_path.as_posix(),
|
||||||
|
]
|
||||||
|
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
additional_folders += [
|
additional_folders += [
|
||||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||||
@@ -99,11 +104,7 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
|||||||
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||||
"LNBITS_DENOMINATION": (
|
"LNBITS_DENOMINATION": settings.lnbits_denomination,
|
||||||
settings.lnbits_denomination
|
|
||||||
if settings.lnbits_denomination == "FakeWallet"
|
|
||||||
else "sats"
|
|
||||||
),
|
|
||||||
"has_holdinvoice": settings.has_holdinvoice,
|
"has_holdinvoice": settings.has_holdinvoice,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ def is_valid_pubkey(pubkey: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, token_expire_minutes: Optional[int] = None) -> str:
|
def create_access_token(data: dict, token_expire_minutes: int | None = None) -> str:
|
||||||
minutes = token_expire_minutes or settings.auth_token_expire_minutes
|
minutes = token_expire_minutes or settings.auth_token_expire_minutes
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
to_encode = {k: v for k, v in data.items() if v is not None}
|
to_encode = {k: v for k, v in data.items() if v is not None}
|
||||||
@@ -223,9 +224,7 @@ def create_access_token(data: dict, token_expire_minutes: Optional[int] = None)
|
|||||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||||
|
|
||||||
|
|
||||||
def encrypt_internal_message(
|
def encrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
|
||||||
m: Optional[str] = None, urlsafe: bool = False
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
"""
|
||||||
Encrypt message with the internal secret key
|
Encrypt message with the internal secret key
|
||||||
|
|
||||||
@@ -238,9 +237,7 @@ def encrypt_internal_message(
|
|||||||
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
|
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
|
||||||
|
|
||||||
|
|
||||||
def decrypt_internal_message(
|
def decrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
|
||||||
m: Optional[str] = None, urlsafe: bool = False
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
"""
|
||||||
Decrypt message with the internal secret key
|
Decrypt message with the internal secret key
|
||||||
|
|
||||||
@@ -253,7 +250,7 @@ def decrypt_internal_message(
|
|||||||
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
|
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
|
||||||
|
|
||||||
|
|
||||||
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
|
def filter_dict_keys(data: dict, filter_keys: list[str] | None) -> dict:
|
||||||
if not filter_keys:
|
if not filter_keys:
|
||||||
# return shallow clone of the dict even if there are no filters
|
# return shallow clone of the dict even if there are no filters
|
||||||
return {**data}
|
return {**data}
|
||||||
@@ -274,7 +271,7 @@ def version_parse(v: str):
|
|||||||
|
|
||||||
|
|
||||||
def is_lnbits_version_ok(
|
def is_lnbits_version_ok(
|
||||||
min_lnbits_version: Optional[str], max_lnbits_version: Optional[str]
|
min_lnbits_version: str | None, max_lnbits_version: str | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if min_lnbits_version and (
|
if min_lnbits_version and (
|
||||||
version_parse(min_lnbits_version) > version_parse(settings.version)
|
version_parse(min_lnbits_version) > version_parse(settings.version)
|
||||||
@@ -351,7 +348,7 @@ def path_segments(path: str) -> list[str]:
|
|||||||
return segments[0:]
|
return segments[0:]
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: Optional[str]) -> str:
|
def normalize_path(path: str | None) -> str:
|
||||||
path = path or ""
|
path = path or ""
|
||||||
return "/" + "/".join(path_segments(path))
|
return "/" + "/".join(path_segments(path))
|
||||||
|
|
||||||
@@ -376,3 +373,27 @@ def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
|||||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||||
)
|
)
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_words(name: str) -> str:
|
||||||
|
# Add space before capital letters (but not at the start)
|
||||||
|
words = re.sub(r"(?<!^)(?=[A-Z])", " ", name)
|
||||||
|
return words.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_snake(name: str) -> str:
|
||||||
|
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
||||||
|
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||||
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_camel_case(v: str) -> bool:
|
||||||
|
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_snake_case(v: str) -> bool:
|
||||||
|
return re.match(r"^[a-z]+(_[a-z0-9]+)*$", v) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def lowercase_first_letter(s: str) -> str:
|
||||||
|
return s[:1].lower() + s[1:] if s else s
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any, Optional, Union
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
@@ -62,7 +62,7 @@ class InstalledExtensionMiddleware:
|
|||||||
|
|
||||||
def _response_by_accepted_type(
|
def _response_by_accepted_type(
|
||||||
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
|
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
|
||||||
) -> Union[HTMLResponse, JSONResponse]:
|
) -> HTMLResponse | JSONResponse:
|
||||||
"""
|
"""
|
||||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||||
as HTTP code. If the `accept` HTTP header is present int the request and
|
as HTTP code. If the `accept` HTTP header is present int the request and
|
||||||
@@ -127,7 +127,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
start_time = datetime.now(timezone.utc)
|
start_time = datetime.now(timezone.utc)
|
||||||
request_details = await self._request_details(request)
|
request_details = await self._request_details(request)
|
||||||
response: Optional[Response] = None
|
response: Response | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
@@ -140,13 +140,13 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
async def _log_audit(
|
async def _log_audit(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Optional[Response],
|
response: Response | None,
|
||||||
duration: float,
|
duration: float,
|
||||||
request_details: Optional[str],
|
request_details: str | None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
http_method = request.scope.get("method", None)
|
http_method = request.scope.get("method", None)
|
||||||
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
|
path: str | None = getattr(request.scope.get("route", {}), "path", None)
|
||||||
response_code = str(response.status_code) if response else None
|
response_code = str(response.status_code) if response else None
|
||||||
if not settings.audit_http_request(http_method, path, response_code):
|
if not settings.audit_http_request(http_method, path, response_code):
|
||||||
return None
|
return None
|
||||||
@@ -178,7 +178,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
async def _request_details(self, request: Request) -> Optional[str]:
|
async def _request_details(self, request: Request) -> str | None:
|
||||||
if not settings.audit_http_request_details():
|
if not settings.audit_http_request_details():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ def main(
|
|||||||
ssl_certfile: str,
|
ssl_certfile: str,
|
||||||
reload: bool,
|
reload: bool,
|
||||||
):
|
):
|
||||||
"""Launched with `poetry run lnbits` at root level"""
|
"""Launched with `uv run lnbits` at root level"""
|
||||||
|
|
||||||
# create data dir if it does not exist
|
# create data dir if it does not exist
|
||||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+16
-8
@@ -51,11 +51,19 @@ class ExtensionsSettings(LNbitsSettings):
|
|||||||
lnbits_admin_extensions: list[str] = Field(default=[])
|
lnbits_admin_extensions: list[str] = Field(default=[])
|
||||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||||
|
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
||||||
lnbits_extensions_manifests: list[str] = Field(
|
lnbits_extensions_manifests: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
lnbits_extensions_builder_manifest_url: str = Field(
|
||||||
|
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extension_builder_working_dir_path(self) -> Path:
|
||||||
|
return Path(settings.lnbits_data_folder, "extensions_builder")
|
||||||
|
|
||||||
|
|
||||||
class ExtensionsInstallSettings(LNbitsSettings):
|
class ExtensionsInstallSettings(LNbitsSettings):
|
||||||
@@ -142,14 +150,14 @@ class ExchangeRateProvider(BaseModel):
|
|||||||
|
|
||||||
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=set())
|
||||||
# upgraded extensions that require API redirects
|
# upgraded extensions that require API redirects
|
||||||
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
|
lnbits_upgraded_extensions: dict[str, 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[RedirectPath] = Field(default=[])
|
||||||
|
|
||||||
# list of all extension ids
|
# list of all extension ids
|
||||||
lnbits_installed_extensions_ids: set[str] = Field(default=[])
|
lnbits_installed_extensions_ids: set[str] = Field(default=set())
|
||||||
|
|
||||||
def find_extension_redirect(
|
def find_extension_redirect(
|
||||||
self, path: str, req_headers: list[tuple[bytes, bytes]]
|
self, path: str, req_headers: list[tuple[bytes, bytes]]
|
||||||
@@ -266,7 +274,7 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
lnbits_default_theme: str = Field(default="salvador")
|
lnbits_default_theme: str = Field(default="salvador")
|
||||||
lnbits_default_border: str = Field(default="hard-border")
|
lnbits_default_border: str = Field(default="hard-border")
|
||||||
lnbits_default_gradient: bool = Field(default=True)
|
lnbits_default_gradient: bool = Field(default=True)
|
||||||
lnbits_default_bgimage: str = Field(default=None)
|
lnbits_default_bgimage: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class OpsSettings(LNbitsSettings):
|
class OpsSettings(LNbitsSettings):
|
||||||
@@ -729,7 +737,7 @@ class AuthMethods(Enum):
|
|||||||
|
|
||||||
class AuthSettings(LNbitsSettings):
|
class AuthSettings(LNbitsSettings):
|
||||||
auth_token_expire_minutes: int = Field(default=525600, gt=0)
|
auth_token_expire_minutes: int = Field(default=525600, gt=0)
|
||||||
auth_all_methods = [a.value for a in AuthMethods]
|
auth_all_methods: list[str] = [a.value for a in AuthMethods]
|
||||||
auth_allowed_methods: list[str] = Field(
|
auth_allowed_methods: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
AuthMethods.user_id_only.value,
|
AuthMethods.user_id_only.value,
|
||||||
@@ -947,7 +955,7 @@ class EnvSettings(LNbitsSettings):
|
|||||||
|
|
||||||
class PersistenceSettings(LNbitsSettings):
|
class PersistenceSettings(LNbitsSettings):
|
||||||
lnbits_data_folder: str = Field(default="./data")
|
lnbits_data_folder: str = Field(default="./data")
|
||||||
lnbits_database_url: str = Field(default=None)
|
lnbits_database_url: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class SuperUserSettings(LNbitsSettings):
|
class SuperUserSettings(LNbitsSettings):
|
||||||
@@ -993,11 +1001,11 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
|
|||||||
lnbits_running: bool = Field(default=True)
|
lnbits_running: bool = Field(default=True)
|
||||||
|
|
||||||
# Remember the latest balance delta in order to compare with the current one
|
# Remember the latest balance delta in order to compare with the current one
|
||||||
latest_balance_delta_sats: int = Field(default=None)
|
latest_balance_delta_sats: int = Field(default=0)
|
||||||
|
|
||||||
lnbits_all_extensions_ids: set[str] = Field(default=[])
|
lnbits_all_extensions_ids: set[str] = Field(default=set())
|
||||||
|
|
||||||
server_startup_time: int = Field(default=time())
|
server_startup_time: int = Field(default=int(time()))
|
||||||
|
|
||||||
has_holdinvoice: bool = Field(default=False)
|
has_holdinvoice: bool = Field(default=False)
|
||||||
has_nodemanager: bool = Field(default=False)
|
has_nodemanager: bool = Field(default=False)
|
||||||
|
|||||||
+1
-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
+3
-3
File diff suppressed because one or more lines are too long
@@ -243,6 +243,13 @@ body.body--dark .q-field--error .q-field__messages {
|
|||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blur-and-disable {
|
||||||
|
filter: blur(4px); /* blur entire element & children */
|
||||||
|
pointer-events: none; /* block mouse interaction */
|
||||||
|
user-select: none; /* prevent text selection */
|
||||||
|
opacity: 0.6; /* optional: faded look */
|
||||||
|
}
|
||||||
|
|
||||||
.lnbits__table-bordered td,
|
.lnbits__table-bordered td,
|
||||||
.lnbits__table-bordered th {
|
.lnbits__table-bordered th {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ window.localisation.en = {
|
|||||||
export_to_phone_desc:
|
export_to_phone_desc:
|
||||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||||
access_wallet_on_mobile: 'Mobile Access',
|
access_wallet_on_mobile: 'Mobile Access',
|
||||||
|
stored_paylinks: 'Stored LNURL pay links',
|
||||||
wallet: 'Wallet: ',
|
wallet: 'Wallet: ',
|
||||||
wallet_name: 'Wallet name',
|
wallet_name: 'Wallet name',
|
||||||
wallets: 'Wallets',
|
wallets: 'Wallets',
|
||||||
@@ -114,6 +115,7 @@ window.localisation.en = {
|
|||||||
payment_check: 'Check payment',
|
payment_check: 'Check payment',
|
||||||
not_enough_funds: 'Not enough funds!',
|
not_enough_funds: 'Not enough funds!',
|
||||||
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
||||||
|
search: 'Search',
|
||||||
invoice_waiting: 'Invoice waiting to be paid',
|
invoice_waiting: 'Invoice waiting to be paid',
|
||||||
payment_received: 'Payment Received',
|
payment_received: 'Payment Received',
|
||||||
payment_sent: 'Payment Sent',
|
payment_sent: 'Payment Sent',
|
||||||
@@ -162,8 +164,12 @@ window.localisation.en = {
|
|||||||
featured: 'Featured',
|
featured: 'Featured',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||||
|
only_admins_can_create_extensions:
|
||||||
|
'Only admin accounts can create extensions',
|
||||||
admin_only: 'Admin Only',
|
admin_only: 'Admin Only',
|
||||||
new_version: 'New Version',
|
new_version: 'New Version',
|
||||||
|
extension_has_free_release: 'Has free releases',
|
||||||
|
extension_has_paid_release: 'Has paid releases',
|
||||||
extension_depends_on: 'Depends on:',
|
extension_depends_on: 'Depends on:',
|
||||||
extension_rating_soon: 'Ratings coming soon',
|
extension_rating_soon: 'Ratings coming soon',
|
||||||
extension_installed_version: 'Installed version',
|
extension_installed_version: 'Installed version',
|
||||||
@@ -418,6 +424,7 @@ window.localisation.en = {
|
|||||||
admin_settings: 'Admin Settings',
|
admin_settings: 'Admin Settings',
|
||||||
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.',
|
||||||
|
create_extension: 'Create Extension',
|
||||||
release_details_error: 'Cannot get the release details.',
|
release_details_error: 'Cannot get the release details.',
|
||||||
pay_from_wallet: 'Pay from Wallet',
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
pay_with: 'Pay with {provider}',
|
pay_with: 'Pay with {provider}',
|
||||||
@@ -487,9 +494,16 @@ window.localisation.en = {
|
|||||||
user_default_extensions_label: 'User extensions',
|
user_default_extensions_label: 'User extensions',
|
||||||
user_default_extensions_hint:
|
user_default_extensions_hint:
|
||||||
'Extensions that will be enabled by default for the users.',
|
'Extensions that will be enabled by default for the users.',
|
||||||
|
extension_builder: 'Extension Builder',
|
||||||
|
extension_builder_manifest_url: 'Extension Builder Manifest URL',
|
||||||
|
extension_builder_manifest_url_hint:
|
||||||
|
'URL to a JSON manifest file with extension builder details',
|
||||||
miscellanous: 'Miscellanous',
|
miscellanous: 'Miscellanous',
|
||||||
misc_disable_extensions: 'Disable Extensions',
|
misc_disable_extensions: 'Disable Extensions',
|
||||||
misc_disable_extensions_label: 'Disable all extensions',
|
misc_disable_extensions_label: 'Disable all extensions',
|
||||||
|
misc_disable_extensions_builder: 'Enable Extensions Builder',
|
||||||
|
misc_disable_extensions_builder_label:
|
||||||
|
'Enable Extensions Builder for non admin users.',
|
||||||
misc_hide_api: 'Hide API',
|
misc_hide_api: 'Hide API',
|
||||||
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
|
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
|
||||||
wallets_management: 'Wallets Management',
|
wallets_management: 'Wallets Management',
|
||||||
@@ -651,5 +665,7 @@ window.localisation.en = {
|
|||||||
callback_success_url_hint:
|
callback_success_url_hint:
|
||||||
'The user will be redirected to this URL after the payment is successful',
|
'The user will be redirected to this URL after the payment is successful',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
not_connected: 'Not Connected'
|
not_connected: 'Not Connected',
|
||||||
|
free: 'Free',
|
||||||
|
paid: 'Paid'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
return moment.utc(date * 1000).fromNow()
|
return moment
|
||||||
|
.utc(date * 1000)
|
||||||
|
.local()
|
||||||
|
.fromNow()
|
||||||
},
|
},
|
||||||
sendTestEmail() {
|
sendTestEmail() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
window.LNbits = {
|
window.LNbits = {
|
||||||
g: window.g,
|
g: window.g,
|
||||||
api: {
|
api: {
|
||||||
request(method, url, apiKey, data) {
|
request(method, url, apiKey, data, options = {}) {
|
||||||
return axios({
|
return axios({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': apiKey
|
'X-Api-Key': apiKey
|
||||||
},
|
},
|
||||||
data: data
|
data: data,
|
||||||
|
...options
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getServerHealth() {
|
getServerHealth() {
|
||||||
@@ -266,10 +267,10 @@ window.LNbits = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||||
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
|
||||||
|
|
||||||
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||||
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra?.tag
|
obj.tag = obj.extra?.tag
|
||||||
|
|||||||
@@ -187,34 +187,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-qrcode', {
|
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-qrcode',
|
|
||||||
components: {
|
|
||||||
QrcodeVue
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
value: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
options: Object
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
custom: {
|
|
||||||
margin: 3,
|
|
||||||
width: 350,
|
|
||||||
size: 350,
|
|
||||||
logo: LNBITS_QR_LOGO
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.custom = {...this.custom, ...this.options}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
window.app.component('lnbits-notifications-btn', {
|
window.app.component('lnbits-notifications-btn', {
|
||||||
template: '#lnbits-notifications-btn',
|
template: '#lnbits-notifications-btn',
|
||||||
mixins: [window.windowMixin],
|
mixins: [window.windowMixin],
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
window.app.component('lnbits-data-fields', {
|
||||||
|
name: 'lnbits-data-fields',
|
||||||
|
template: '#lnbits-data-fields',
|
||||||
|
props: ['fields', 'hide-advanced'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
fieldTypes: [
|
||||||
|
{label: 'Text', value: 'str'},
|
||||||
|
{label: 'Integer', value: 'int'},
|
||||||
|
{label: 'Float', value: 'float'},
|
||||||
|
{label: 'Boolean', value: 'bool'},
|
||||||
|
{label: 'Date Time', value: 'datetime'},
|
||||||
|
{label: 'JSON', value: 'json'},
|
||||||
|
{label: 'Wallet Select', value: 'wallet'},
|
||||||
|
{label: 'Currency Select', value: 'currency'}
|
||||||
|
],
|
||||||
|
fieldsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Field Name',
|
||||||
|
field: 'name',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Type',
|
||||||
|
field: 'type',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'label',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Label',
|
||||||
|
field: 'label',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'hint',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Hint',
|
||||||
|
field: 'hint',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'optional',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Optional',
|
||||||
|
field: 'optional',
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
sortBy: 'name',
|
||||||
|
rowsPerPage: 100,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 100
|
||||||
|
},
|
||||||
|
search: null,
|
||||||
|
hideEmpty: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addField: function () {
|
||||||
|
this.fields.push({
|
||||||
|
name: 'field_name_' + (this.fields.length + 1),
|
||||||
|
type: 'text',
|
||||||
|
label: '',
|
||||||
|
hint: '',
|
||||||
|
optional: true,
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
editable: true,
|
||||||
|
fields: [] // For nested fields in JSON type
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeField: function (field) {
|
||||||
|
const index = this.fields.indexOf(field)
|
||||||
|
if (index > -1) {
|
||||||
|
this.fields.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
if (!this.hideAdvanced) {
|
||||||
|
this.fieldsTable.columns.push(
|
||||||
|
{
|
||||||
|
name: 'editable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Editable',
|
||||||
|
field: 'editable',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sortable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Sortable',
|
||||||
|
field: 'sortable',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'searchable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Searchable',
|
||||||
|
field: 'searchable',
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
window.app.component('lnbits-qrcode-lnurl', {
|
||||||
|
template: '#lnbits-qrcode-lnurl',
|
||||||
|
mixins: [window.windowMixin],
|
||||||
|
props: {
|
||||||
|
url: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
prefix: {
|
||||||
|
type: String,
|
||||||
|
default: 'lnurlp'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tab: 'bech32',
|
||||||
|
lnurl: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setLnurl() {
|
||||||
|
if (this.tab == 'bech32') {
|
||||||
|
const bytes = new TextEncoder().encode(this.url)
|
||||||
|
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
||||||
|
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
||||||
|
} else if (this.tab == 'lud17') {
|
||||||
|
if (this.url.startsWith('http://')) {
|
||||||
|
this.lnurl = this.url.replace('http://', this.prefix + '://')
|
||||||
|
} else {
|
||||||
|
this.lnurl = this.url.replace('https://', this.prefix + '://')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$emit('update:lnurl', this.lnurl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
url() {
|
||||||
|
this.setLnurl()
|
||||||
|
},
|
||||||
|
tab() {
|
||||||
|
this.setLnurl()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.setLnurl()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
window.app.component('lnbits-qrcode', {
|
||||||
|
mixins: [window.windowMixin],
|
||||||
|
template: '#lnbits-qrcode',
|
||||||
|
components: {
|
||||||
|
QrcodeVue
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
nfc: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showButtons: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 450
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
default: LNBITS_QR_LOGO
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
nfcTagWriting: false,
|
||||||
|
nfcSupported: typeof NDEFReader != 'undefined'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickQrCode(event) {
|
||||||
|
if (this.href === '') {
|
||||||
|
this.copyText(this.value)
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async writeNfcTag() {
|
||||||
|
try {
|
||||||
|
if (!this.nfcSupported) {
|
||||||
|
throw {
|
||||||
|
toString: function () {
|
||||||
|
return 'NFC not supported on this device or browser.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ndef = new NDEFReader()
|
||||||
|
|
||||||
|
this.nfcTagWriting = true
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Tap your NFC tag to write the LNURL-withdraw link to it.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await ndef.write({
|
||||||
|
records: [{recordType: 'url', data: this.value, lang: 'en'}]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'NFC tag written successfully.'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error
|
||||||
|
? error.toString()
|
||||||
|
: 'An unexpected error has occurred.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadSVG() {
|
||||||
|
const filename = 'qrcode.svg'
|
||||||
|
const svg = this.$refs.qrCode.$el
|
||||||
|
if (!svg) {
|
||||||
|
console.error('SVG element not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the SVG content
|
||||||
|
const serializer = new XMLSerializer()
|
||||||
|
let source = serializer.serializeToString(svg)
|
||||||
|
|
||||||
|
// Add SVG namespace if not present
|
||||||
|
if (!source.match(/^<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) {
|
||||||
|
source = source.replace(
|
||||||
|
/^<svg/,
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg"'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add XML declaration
|
||||||
|
source = '<?xml version="1.0" standalone="no"?>\n' + source
|
||||||
|
|
||||||
|
// Convert to Blob and trigger download
|
||||||
|
const blob = new Blob([source], {type: 'image/svg+xml;charset=utf-8'})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
|
||||||
|
this.$refs.qrCode.$el.setAttribute('width', '100%')
|
||||||
|
this.$refs.qrCode.$el.removeAttribute('height')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -154,7 +154,7 @@ function confettiStars() {
|
|||||||
setTimeout(shoot, 200)
|
setTimeout(shoot, 200)
|
||||||
}
|
}
|
||||||
!(function (t, e) {
|
!(function (t, e) {
|
||||||
!(function t(e, n, a, i) {
|
;(!(function t(e, n, a, i) {
|
||||||
var o = !!(
|
var o = !!(
|
||||||
e.Worker &&
|
e.Worker &&
|
||||||
e.Blob &&
|
e.Blob &&
|
||||||
@@ -248,12 +248,12 @@ function confettiStars() {
|
|||||||
function e(e, n) {
|
function e(e, n) {
|
||||||
t.postMessage({options: e || {}, callback: n})
|
t.postMessage({options: e || {}, callback: n})
|
||||||
}
|
}
|
||||||
;(t.init = function (e) {
|
;((t.init = function (e) {
|
||||||
var n = e.transferControlToOffscreen()
|
var n = e.transferControlToOffscreen()
|
||||||
t.postMessage({canvas: n}, [n])
|
t.postMessage({canvas: n}, [n])
|
||||||
}),
|
}),
|
||||||
(t.fire = function (n, a, i) {
|
(t.fire = function (n, a, i) {
|
||||||
if (g) return e(n, null), g
|
if (g) return (e(n, null), g)
|
||||||
var o = Math.random().toString(36).slice(2)
|
var o = Math.random().toString(36).slice(2)
|
||||||
return (g = l(function (a) {
|
return (g = l(function (a) {
|
||||||
function r(e) {
|
function r(e) {
|
||||||
@@ -264,15 +264,15 @@ function confettiStars() {
|
|||||||
i(),
|
i(),
|
||||||
a())
|
a())
|
||||||
}
|
}
|
||||||
t.addEventListener('message', r),
|
;(t.addEventListener('message', r),
|
||||||
e(n, o),
|
e(n, o),
|
||||||
(m[o] = r.bind(null, {data: {callback: o}}))
|
(m[o] = r.bind(null, {data: {callback: o}})))
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
(t.reset = function () {
|
(t.reset = function () {
|
||||||
for (var e in (t.postMessage({reset: !0}), m))
|
for (var e in (t.postMessage({reset: !0}), m))
|
||||||
m[e](), delete m[e]
|
(m[e](), delete m[e])
|
||||||
})
|
}))
|
||||||
})(h)
|
})(h)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
@@ -328,12 +328,12 @@ function confettiStars() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
function k(t) {
|
function k(t) {
|
||||||
;(t.width = document.documentElement.clientWidth),
|
;((t.width = document.documentElement.clientWidth),
|
||||||
(t.height = document.documentElement.clientHeight)
|
(t.height = document.documentElement.clientHeight))
|
||||||
}
|
}
|
||||||
function I(t) {
|
function I(t) {
|
||||||
var e = t.getBoundingClientRect()
|
var e = t.getBoundingClientRect()
|
||||||
;(t.width = e.width), (t.height = e.height)
|
;((t.width = e.width), (t.height = e.height))
|
||||||
}
|
}
|
||||||
function T(t, e, n, o, r) {
|
function T(t, e, n, o, r) {
|
||||||
var c,
|
var c,
|
||||||
@@ -342,10 +342,10 @@ function confettiStars() {
|
|||||||
d = t.getContext('2d'),
|
d = t.getContext('2d'),
|
||||||
f = l(function (e) {
|
f = l(function (e) {
|
||||||
function l() {
|
function l() {
|
||||||
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
|
;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e())
|
||||||
}
|
}
|
||||||
;(c = b.frame(function e() {
|
;((c = b.frame(function e() {
|
||||||
!a ||
|
;(!a ||
|
||||||
(o.width === i.width && o.height === i.height) ||
|
(o.width === i.width && o.height === i.height) ||
|
||||||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
|
((o.width = t.width = i.width), (o.height = t.height = i.height)),
|
||||||
o.width ||
|
o.width ||
|
||||||
@@ -354,7 +354,7 @@ function confettiStars() {
|
|||||||
d.clearRect(0, 0, o.width, o.height),
|
d.clearRect(0, 0, o.width, o.height),
|
||||||
(u = u.filter(function (t) {
|
(u = u.filter(function (t) {
|
||||||
return (function (t, e) {
|
return (function (t, e) {
|
||||||
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
|
;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
|
||||||
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
|
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
|
||||||
(e.wobble += 0.1),
|
(e.wobble += 0.1),
|
||||||
(e.velocity *= e.decay),
|
(e.velocity *= e.decay),
|
||||||
@@ -363,7 +363,7 @@ function confettiStars() {
|
|||||||
(e.tiltCos = Math.cos(e.tiltAngle)),
|
(e.tiltCos = Math.cos(e.tiltAngle)),
|
||||||
(e.random = Math.random() + 5),
|
(e.random = Math.random() + 5),
|
||||||
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
|
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
|
||||||
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
|
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)))
|
||||||
var n = e.tick++ / e.totalTicks,
|
var n = e.tick++ / e.totalTicks,
|
||||||
a = e.x + e.random * e.tiltCos,
|
a = e.x + e.random * e.tiltCos,
|
||||||
i = e.y + e.random * e.tiltSin,
|
i = e.y + e.random * e.tiltSin,
|
||||||
@@ -393,12 +393,12 @@ function confettiStars() {
|
|||||||
2 * Math.PI
|
2 * Math.PI
|
||||||
)
|
)
|
||||||
: (function (t, e, n, a, i, o, r, l, c) {
|
: (function (t, e, n, a, i, o, r, l, c) {
|
||||||
t.save(),
|
;(t.save(),
|
||||||
t.translate(e, n),
|
t.translate(e, n),
|
||||||
t.rotate(o),
|
t.rotate(o),
|
||||||
t.scale(a, i),
|
t.scale(a, i),
|
||||||
t.arc(0, 0, 1, r, l, c),
|
t.arc(0, 0, 1, r, l, c),
|
||||||
t.restore()
|
t.restore())
|
||||||
})(
|
})(
|
||||||
t,
|
t,
|
||||||
e.x,
|
e.x,
|
||||||
@@ -420,18 +420,18 @@ function confettiStars() {
|
|||||||
})(d, t)
|
})(d, t)
|
||||||
})).length
|
})).length
|
||||||
? (c = b.frame(e))
|
? (c = b.frame(e))
|
||||||
: l()
|
: l())
|
||||||
})),
|
})),
|
||||||
(s = l)
|
(s = l))
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
addFettis: function (t) {
|
addFettis: function (t) {
|
||||||
return (u = u.concat(t)), f
|
return ((u = u.concat(t)), f)
|
||||||
},
|
},
|
||||||
canvas: t,
|
canvas: t,
|
||||||
promise: f,
|
promise: f,
|
||||||
reset: function () {
|
reset: function () {
|
||||||
c && b.cancel(c), s && s()
|
;(c && b.cancel(c), s && s())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,7 +466,7 @@ function confettiStars() {
|
|||||||
k = p(e, 'scalar'),
|
k = p(e, 'scalar'),
|
||||||
I = (function (t) {
|
I = (function (t) {
|
||||||
var e = p(t, 'origin', Object)
|
var e = p(t, 'origin', Object)
|
||||||
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
|
return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
|
||||||
})(e),
|
})(e),
|
||||||
E = d,
|
E = d,
|
||||||
S = [],
|
S = [],
|
||||||
@@ -531,7 +531,7 @@ function confettiStars() {
|
|||||||
return l(function (t) {
|
return l(function (t) {
|
||||||
t()
|
t()
|
||||||
})
|
})
|
||||||
i && a
|
;(i && a
|
||||||
? (t = a.canvas)
|
? (t = a.canvas)
|
||||||
: i &&
|
: i &&
|
||||||
!t &&
|
!t &&
|
||||||
@@ -547,7 +547,7 @@ function confettiStars() {
|
|||||||
)
|
)
|
||||||
})(g)),
|
})(g)),
|
||||||
document.body.appendChild(t)),
|
document.body.appendChild(t)),
|
||||||
r && !d && u(t)
|
r && !d && u(t))
|
||||||
var m = {width: t.width, height: t.height}
|
var m = {width: t.width, height: t.height}
|
||||||
function b() {
|
function b() {
|
||||||
if (s) {
|
if (s) {
|
||||||
@@ -564,9 +564,9 @@ function confettiStars() {
|
|||||||
m.width = m.height = null
|
m.width = m.height = null
|
||||||
}
|
}
|
||||||
function v() {
|
function v() {
|
||||||
;(a = null),
|
;((a = null),
|
||||||
r && e.removeEventListener('resize', b),
|
r && e.removeEventListener('resize', b),
|
||||||
i && t && (document.body.removeChild(t), (t = null), (d = !1))
|
i && t && (document.body.removeChild(t), (t = null), (d = !1)))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
s && !d && s.init(t),
|
s && !d && s.init(t),
|
||||||
@@ -578,12 +578,13 @@ function confettiStars() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
(g.reset = function () {
|
(g.reset = function () {
|
||||||
s && s.reset(), a && a.reset()
|
;(s && s.reset(), a && a.reset())
|
||||||
}),
|
}),
|
||||||
g
|
g
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
|
;((n.exports = E(null, {useWorker: !0, resize: !0})),
|
||||||
|
(n.exports.create = E))
|
||||||
})(
|
})(
|
||||||
(function () {
|
(function () {
|
||||||
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
|
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
|
||||||
@@ -591,5 +592,5 @@ function confettiStars() {
|
|||||||
e,
|
e,
|
||||||
!1
|
!1
|
||||||
),
|
),
|
||||||
(t.confetti = e.exports)
|
(t.confetti = e.exports))
|
||||||
})(window, {})
|
})(window, {})
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
window.ExtensionsBuilderPageLogic = {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
step: 1,
|
||||||
|
previewStepNames: {
|
||||||
|
2: 'settings',
|
||||||
|
3: 'owner_data',
|
||||||
|
4: 'client_data',
|
||||||
|
5: 'public_page'
|
||||||
|
},
|
||||||
|
extensionDataCleanString: '',
|
||||||
|
extensionData: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
stub_version: '',
|
||||||
|
short_description: '',
|
||||||
|
description: '',
|
||||||
|
public_page: {
|
||||||
|
has_public_page: true,
|
||||||
|
owner_data_fields: {
|
||||||
|
name: '',
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
client_data_fields: {
|
||||||
|
public_inputs: []
|
||||||
|
},
|
||||||
|
action_fields: {
|
||||||
|
generate_action: true,
|
||||||
|
generate_payment_logic: false,
|
||||||
|
wallet_id: '',
|
||||||
|
currency: '',
|
||||||
|
amount: '',
|
||||||
|
paid_flag: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview_action: {
|
||||||
|
is_preview_mode: false,
|
||||||
|
is_settings_preview: false,
|
||||||
|
is_owner_data_preview: false,
|
||||||
|
is_client_data_preview: false,
|
||||||
|
is_public_page_preview: false
|
||||||
|
},
|
||||||
|
settings_data: {
|
||||||
|
name: 'Settings',
|
||||||
|
enabled: true,
|
||||||
|
type: 'user',
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
owner_data: {
|
||||||
|
name: 'OwnerData',
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
client_data: {
|
||||||
|
enabled: true,
|
||||||
|
name: 'ClientData',
|
||||||
|
fields: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sampleField: {
|
||||||
|
name: 'name',
|
||||||
|
type: 'str',
|
||||||
|
label: 'Name',
|
||||||
|
hint: '',
|
||||||
|
optional: true,
|
||||||
|
editable: true,
|
||||||
|
searchable: true,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
|
||||||
|
settingsTypes: [
|
||||||
|
{label: 'User Settings', value: 'user'},
|
||||||
|
{label: 'Admin Settings', value: 'admin'}
|
||||||
|
],
|
||||||
|
amountSource: [
|
||||||
|
{label: 'Client Data', value: 'client_data'},
|
||||||
|
{label: 'Owner Data', value: 'owner_data'}
|
||||||
|
],
|
||||||
|
extensionStubVersions: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'extensionData.public_page.action_fields.amount_source': function (
|
||||||
|
newVal,
|
||||||
|
oldVal
|
||||||
|
) {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
this.extensionData.public_page.action_fields.amount = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
paymentActionAmountFields() {
|
||||||
|
const amount_source =
|
||||||
|
this.extensionData.public_page.action_fields.amount_source
|
||||||
|
console.log('### amount_source:', amount_source)
|
||||||
|
if (!amount_source) return ['']
|
||||||
|
|
||||||
|
if (amount_source === 'owner_data') {
|
||||||
|
return [''].concat(
|
||||||
|
this.extensionData.owner_data.fields
|
||||||
|
.filter(f => f.type === 'int' || f.type === 'float')
|
||||||
|
.map(f => f.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (amount_source === 'client_data') {
|
||||||
|
return [''].concat(
|
||||||
|
this.extensionData.client_data.fields
|
||||||
|
.filter(f => f.type === 'int' || f.type === 'float')
|
||||||
|
.map(f => f.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveState() {
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
'lnbits.extension.builder.data',
|
||||||
|
JSON.stringify(this.extensionData)
|
||||||
|
)
|
||||||
|
this.$q.localStorage.set('lnbits.extension.builder.step', this.step)
|
||||||
|
},
|
||||||
|
nextStep() {
|
||||||
|
this.saveState()
|
||||||
|
this.$refs.stepper.next()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
previousStep() {
|
||||||
|
this.saveState()
|
||||||
|
this.$refs.stepper.previous()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
onStepChange() {
|
||||||
|
this.saveState()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
clearAllData() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Are you sure you want to clear all data? This action cannot be undone.'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.extensionData = JSON.parse(this.extensionDataCleanString)
|
||||||
|
this.$q.localStorage.remove('lnbits.extension.builder.data')
|
||||||
|
this.$refs.stepper.set(1)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportJsonData() {
|
||||||
|
const status = Quasar.exportFile(
|
||||||
|
`${this.extensionData.id || 'data-export'}.json`,
|
||||||
|
JSON.stringify(this.extensionData, null, 2),
|
||||||
|
'text/json'
|
||||||
|
)
|
||||||
|
if (status !== true) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'Browser denied file download...',
|
||||||
|
color: 'negative',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'File downloaded!',
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'file_download'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJsonDataInput(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = e => {
|
||||||
|
this.extensionData = {
|
||||||
|
...this.extensionData,
|
||||||
|
...JSON.parse(e.target.result)
|
||||||
|
}
|
||||||
|
this.$refs.extensionDataInput.value = null
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'File loaded!',
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'file_upload'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
},
|
||||||
|
async buildExtension() {
|
||||||
|
try {
|
||||||
|
const options = {responseType: 'blob'}
|
||||||
|
const response = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/zip',
|
||||||
|
null,
|
||||||
|
this.extensionData,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
// download the zip file
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${this.extensionData.id || 'lnbits-extension'}.zip`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async buildExtensionAndDeploy() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/deploy',
|
||||||
|
null,
|
||||||
|
this.extensionData
|
||||||
|
)
|
||||||
|
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: data.message || 'Extension deployed!',
|
||||||
|
color: 'positive'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cleanCacheData() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Are you sure you want to clean the cache data? This action cannot be undone.',
|
||||||
|
'Clean Cache Data'
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/api/v1/extension/builder',
|
||||||
|
null,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: data.message || 'Cache data cleaned!',
|
||||||
|
color: 'positive'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async previewExtension(previewPageName) {
|
||||||
|
this.saveState()
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/preview',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
...this.extensionData,
|
||||||
|
...{
|
||||||
|
preview_action: {
|
||||||
|
is_preview_mode: !!previewPageName,
|
||||||
|
is_settings_preview: previewPageName === 'settings',
|
||||||
|
is_owner_data_preview: previewPageName === 'owner_data',
|
||||||
|
is_client_data_preview: previewPageName === 'client_data',
|
||||||
|
is_public_page_preview: previewPageName === 'public_page'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.refreshIframe(previewPageName)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshPreview() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const stepName = this.previewStepNames[`${this.step}`] || ''
|
||||||
|
if (!stepName) return
|
||||||
|
this.previewExtension(stepName)
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
async getStubExtensionReleases() {
|
||||||
|
try {
|
||||||
|
const stub_ext_id = 'extension_builder_stub'
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/extension/${stub_ext_id}/releases`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.extensionStubVersions = data.sort((a, b) =>
|
||||||
|
a.version < b.version ? 1 : -1
|
||||||
|
)
|
||||||
|
this.extensionData.stub_version = this.extensionStubVersions[0]
|
||||||
|
? this.extensionStubVersions[0].version
|
||||||
|
: ''
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshIframe(previewPageName = '') {
|
||||||
|
const iframe = this.$refs[`iframeStep${this.step}`]
|
||||||
|
if (!iframe) {
|
||||||
|
console.warn('Extension Builder Preview iframe not loaded yet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iframe.onload = () => {
|
||||||
|
const iframeDoc =
|
||||||
|
iframe.contentDocument || iframe.contentWindow.document
|
||||||
|
|
||||||
|
iframeDoc.body.style.transform = 'scale(0.8)'
|
||||||
|
iframeDoc.body.style.transformOrigin = 'center top'
|
||||||
|
}
|
||||||
|
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
|
||||||
|
},
|
||||||
|
initBasicData() {
|
||||||
|
this.extensionData.owner_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionData.client_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionData.settings_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionDataCleanString = JSON.stringify(this.extensionData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.initBasicData()
|
||||||
|
|
||||||
|
const extensionData = this.$q.localStorage.getItem(
|
||||||
|
'lnbits.extension.builder.data'
|
||||||
|
)
|
||||||
|
if (extensionData) {
|
||||||
|
this.extensionData = {...this.extensionData, ...JSON.parse(extensionData)}
|
||||||
|
}
|
||||||
|
const step = +this.$q.localStorage.getItem('lnbits.extension.builder.step')
|
||||||
|
if (step) {
|
||||||
|
this.step = step
|
||||||
|
}
|
||||||
|
if (this.g.user.admin) {
|
||||||
|
this.getStubExtensionReleases()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.refreshIframe()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
mixins: [windowMixin]
|
||||||
|
}
|
||||||
@@ -184,6 +184,15 @@ const routes = [
|
|||||||
scripts: ['/static/js/extensions.js']
|
scripts: ['/static/js/extensions.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/extensions/builder',
|
||||||
|
name: 'ExtensionsBuilder',
|
||||||
|
component: DynamicComponent,
|
||||||
|
props: {
|
||||||
|
fetchUrl: '/extensions/builder',
|
||||||
|
scripts: ['/static/js/extensions_builder.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/account',
|
path: '/account',
|
||||||
name: 'Account',
|
name: 'Account',
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
|||||||
return LNbits.utils.formatDate(this.ts)
|
return LNbits.utils.formatDate(this.ts)
|
||||||
},
|
},
|
||||||
dateFrom() {
|
dateFrom() {
|
||||||
return moment.utc(this.date).fromNow()
|
return moment.utc(this.date).local().fromNow()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
|
|||||||
if (p.extra && p.extra.tag) {
|
if (p.extra && p.extra.tag) {
|
||||||
p.tag = p.extra.tag
|
p.tag = p.extra.tag
|
||||||
}
|
}
|
||||||
p.timeFrom = moment.utc(p.created_at).fromNow()
|
p.timeFrom = moment.utc(p.created_at).local().fromNow()
|
||||||
p.outgoing = p.amount < 0
|
p.outgoing = p.amount < 0
|
||||||
p.amount =
|
p.amount =
|
||||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||||
|
|||||||
+88
-23
@@ -5,6 +5,7 @@ window.WalletPageLogic = {
|
|||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
baseUrl: `${window.location.protocol}//${window.location.host}/`,
|
baseUrl: `${window.location.protocol}//${window.location.host}/`,
|
||||||
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
|
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
|
||||||
|
stored_paylinks: [],
|
||||||
parse: {
|
parse: {
|
||||||
show: false,
|
show: false,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
@@ -44,7 +45,6 @@ window.WalletPageLogic = {
|
|||||||
payment_hash: null
|
payment_hash: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
invoiceQrCode: '',
|
|
||||||
disclaimerDialog: {
|
disclaimerDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
location: window.location
|
location: window.location
|
||||||
@@ -141,6 +141,13 @@ window.WalletPageLogic = {
|
|||||||
},
|
},
|
||||||
canPay() {
|
canPay() {
|
||||||
if (!this.parse.invoice) return false
|
if (!this.parse.invoice) return false
|
||||||
|
if (this.parse.invoice.expired) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'Invoice has expired',
|
||||||
|
color: 'negative'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
return this.parse.invoice.sat <= this.g.wallet.sat
|
return this.parse.invoice.sat <= this.g.wallet.sat
|
||||||
},
|
},
|
||||||
formattedAmount() {
|
formattedAmount() {
|
||||||
@@ -170,6 +177,10 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
dateFromNow(unix) {
|
||||||
|
const date = new Date(unix * 1000)
|
||||||
|
return moment.utc(date).local().fromNow()
|
||||||
|
},
|
||||||
formatFiatAmount(amount, currency) {
|
formatFiatAmount(amount, currency) {
|
||||||
this.update.currency = currency
|
this.update.currency = currency
|
||||||
this.formattedFiatAmount = LNbits.utils.formatCurrency(
|
this.formattedFiatAmount = LNbits.utils.formatCurrency(
|
||||||
@@ -275,7 +286,10 @@ window.WalletPageLogic = {
|
|||||||
this.readNfcTag()
|
this.readNfcTag()
|
||||||
}
|
}
|
||||||
// WITHDRAW
|
// WITHDRAW
|
||||||
if (response.data.extra.lnurl_response !== null) {
|
if (
|
||||||
|
this.receive.lnurl &&
|
||||||
|
response.data.extra?.lnurl_response !== null
|
||||||
|
) {
|
||||||
if (response.data.extra.lnurl_response === false) {
|
if (response.data.extra.lnurl_response === false) {
|
||||||
response.data.extra.lnurl_response = `Unable to connect`
|
response.data.extra.lnurl_response = `Unable to connect`
|
||||||
}
|
}
|
||||||
@@ -298,13 +312,6 @@ window.WalletPageLogic = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hack as rendering in dialog causes reactivity issues. Does speed up, as only rendering lnbits-qrcode once.
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.invoiceQrCode = document.getElementById(
|
|
||||||
'hiddenQrCodeContainer'
|
|
||||||
).innerHTML
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
@@ -342,11 +349,9 @@ window.WalletPageLogic = {
|
|||||||
},
|
},
|
||||||
lnurlScan() {
|
lnurlScan() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request('POST', '/api/v1/lnurlscan', this.g.wallet.adminkey, {
|
||||||
'GET',
|
lnurl: this.parse.data.request
|
||||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
})
|
||||||
this.g.wallet.adminkey
|
|
||||||
)
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const data = response.data
|
const data = response.data
|
||||||
if (data.status === 'ERROR') {
|
if (data.status === 'ERROR') {
|
||||||
@@ -394,19 +399,29 @@ window.WalletPageLogic = {
|
|||||||
this.decodeRequest()
|
this.decodeRequest()
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
|
isLnurl(req) {
|
||||||
|
return (
|
||||||
|
req.toLowerCase().startsWith('lnurl1') ||
|
||||||
|
req.startsWith('lnurlp://') ||
|
||||||
|
req.startsWith('lnurlw://') ||
|
||||||
|
req.startsWith('lnurlauth://') ||
|
||||||
|
req.match(/[\w.+-~_]+@[\w.+-~_]/)
|
||||||
|
)
|
||||||
|
},
|
||||||
decodeRequest() {
|
decodeRequest() {
|
||||||
this.parse.show = true
|
this.parse.show = true
|
||||||
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
|
this.parse.data.request = this.parse.data.request.trim()
|
||||||
let req = this.parse.data.request
|
const req = this.parse.data.request.toLowerCase()
|
||||||
if (req.startsWith('lightning:')) {
|
if (req.startsWith('lightning:')) {
|
||||||
this.parse.data.request = req.slice(10)
|
this.parse.data.request = this.parse.data.request.slice(10)
|
||||||
} else if (req.startsWith('lnurl:')) {
|
} else if (req.startsWith('lnurl:')) {
|
||||||
this.parse.data.request = req.slice(6)
|
this.parse.data.request = this.parse.data.request.slice(6)
|
||||||
} else if (req.includes('lightning=lnurl1')) {
|
} else if (req.includes('lightning=lnurl1')) {
|
||||||
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
|
this.parse.data.request = this.parse.data.request
|
||||||
|
.split('lightning=')[1]
|
||||||
|
.split('&')[0]
|
||||||
}
|
}
|
||||||
req = this.parse.data.request
|
if (this.isLnurl(this.parse.data.request)) {
|
||||||
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
|
|
||||||
this.lnurlScan()
|
this.lnurlScan()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -461,8 +476,14 @@ window.WalletPageLogic = {
|
|||||||
createdDate,
|
createdDate,
|
||||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||||
)
|
)
|
||||||
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
cleanInvoice.expireDateFrom = moment
|
||||||
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
.utc(expireDate)
|
||||||
|
.local()
|
||||||
|
.fromNow()
|
||||||
|
cleanInvoice.createdDateFrom = moment
|
||||||
|
.utc(createdDate)
|
||||||
|
.local()
|
||||||
|
.fromNow()
|
||||||
|
|
||||||
cleanInvoice.expired = false // TODO
|
cleanInvoice.expired = false // TODO
|
||||||
}
|
}
|
||||||
@@ -518,6 +539,7 @@ window.WalletPageLogic = {
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
||||||
res: this.parse.lnurlpay,
|
res: this.parse.lnurlpay,
|
||||||
|
lnurl: this.parse.data.request,
|
||||||
unit: this.parse.data.unit,
|
unit: this.parse.data.unit,
|
||||||
amount: this.parse.data.amount * 1000,
|
amount: this.parse.data.amount * 1000,
|
||||||
comment: this.parse.data.comment,
|
comment: this.parse.data.comment,
|
||||||
@@ -559,6 +581,7 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
authLnurl() {
|
authLnurl() {
|
||||||
const dismissAuthMsg = Quasar.Notify.create({
|
const dismissAuthMsg = Quasar.Notify.create({
|
||||||
@@ -1080,9 +1103,51 @@ window.WalletPageLogic = {
|
|||||||
saveChartsPreferences() {
|
saveChartsPreferences() {
|
||||||
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
|
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
|
||||||
this.refreshCharts()
|
this.refreshCharts()
|
||||||
|
},
|
||||||
|
updatePaylinks() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
`/api/v1/wallet/stored_paylinks/${this.g.wallet.id}`,
|
||||||
|
this.g.wallet.adminkey,
|
||||||
|
{
|
||||||
|
links: this.stored_paylinks
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'Paylinks updated.',
|
||||||
|
type: 'positive',
|
||||||
|
timeout: 3500
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendToPaylink(lnurl) {
|
||||||
|
this.parse.data.request = lnurl
|
||||||
|
this.parse.show = true
|
||||||
|
this.lnurlScan()
|
||||||
|
},
|
||||||
|
editPaylink() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.updatePaylinks()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deletePaylink(lnurl) {
|
||||||
|
const links = []
|
||||||
|
this.stored_paylinks.forEach(link => {
|
||||||
|
if (link.lnurl !== lnurl) {
|
||||||
|
links.push(link)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.stored_paylinks = links
|
||||||
|
this.updatePaylinks()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
this.stored_paylinks = wallet.stored_paylinks.links
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
if (urlParams.has('lightning') || urlParams.has('lnurl')) {
|
if (urlParams.has('lightning') || urlParams.has('lnurl')) {
|
||||||
this.parse.data.request =
|
this.parse.data.request =
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ body.body--dark .q-field--error {
|
|||||||
.lnbits__dialog-card {
|
.lnbits__dialog-card {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
.blur-and-disable {
|
||||||
|
filter: blur(4px); /* blur entire element & children */
|
||||||
|
pointer-events: none; /* block mouse interaction */
|
||||||
|
user-select: none; /* prevent text selection */
|
||||||
|
opacity: 0.6; /* optional: faded look */
|
||||||
|
}
|
||||||
|
|
||||||
.lnbits__table-bordered td,
|
.lnbits__table-bordered td,
|
||||||
.lnbits__table-bordered th {
|
.lnbits__table-bordered th {
|
||||||
|
|||||||
@@ -38,8 +38,11 @@
|
|||||||
"js/bolt11-decoder.js"
|
"js/bolt11-decoder.js"
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
|
"js/components/lnbits-qrcode.js",
|
||||||
|
"js/components/lnbits-qrcode-lnurl.js",
|
||||||
"js/components/lnbits-funding-sources.js",
|
"js/components/lnbits-funding-sources.js",
|
||||||
"js/components/extension-settings.js",
|
"js/components/extension-settings.js",
|
||||||
|
"js/components/data-fields.js",
|
||||||
"js/components/payment-list.js",
|
"js/components/payment-list.js",
|
||||||
"js/components.js",
|
"js/components.js",
|
||||||
"js/init-app.js"
|
"js/init-app.js"
|
||||||
|
|||||||
Vendored
+427
-305
File diff suppressed because it is too large
Load Diff
+2
-6
@@ -1,11 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import (
|
|
||||||
Callable,
|
|
||||||
Optional,
|
|
||||||
)
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -84,7 +80,7 @@ invoice_listeners: dict[str, asyncio.Queue] = {}
|
|||||||
|
|
||||||
# TODO: name should not be optional
|
# TODO: name should not be optional
|
||||||
# some extensions still dont use a name, but they should
|
# some extensions still dont use a name, but they should
|
||||||
def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = None):
|
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
|
||||||
"""
|
"""
|
||||||
A method intended for extensions (and core/tasks.py) to call when they want to be
|
A method intended for extensions (and core/tasks.py) to call when they want to be
|
||||||
notified about new invoice payments incoming. Will emit all incoming payments.
|
notified about new invoice payments incoming. Will emit all incoming payments.
|
||||||
|
|||||||
+286
-50
@@ -370,7 +370,12 @@
|
|||||||
v-if="extras.length"
|
v-if="extras.length"
|
||||||
>
|
>
|
||||||
<template v-for="entry in extras">
|
<template v-for="entry in extras">
|
||||||
<q-item v-if="!!entry.value" key="entry.key" class="text-grey-4">
|
<q-item
|
||||||
|
v-if="!!entry.value"
|
||||||
|
key="entry.key"
|
||||||
|
class="text-grey-4"
|
||||||
|
style="white-space: normal; word-break: break-all"
|
||||||
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label v-text="entry.key"></q-item-label>
|
<q-item-label v-text="entry.key"></q-item-label>
|
||||||
<q-item-label caption v-text="entry.value"></q-item-label>
|
<q-item-label caption v-text="entry.value"></q-item-label>
|
||||||
@@ -612,21 +617,82 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="lnbits-qrcode">
|
<template id="lnbits-qrcode">
|
||||||
<div class="qrcode__wrapper">
|
<div
|
||||||
<qrcode-vue
|
class="qrcode__outer"
|
||||||
:value="value"
|
:style="`margin: 13px auto; max-width: ${maxWidth}px`"
|
||||||
level="Q"
|
>
|
||||||
render-as="svg"
|
<div ref="qrWrapper" class="qrcode__wrapper">
|
||||||
:margin="custom.margin"
|
<a
|
||||||
:size="custom.width"
|
:href="href"
|
||||||
class="rounded-borders"
|
:title="href === '' ? value : href"
|
||||||
></qrcode-vue>
|
@click="clickQrCode"
|
||||||
<img
|
class="no-link full-width"
|
||||||
v-if="custom.logo"
|
>
|
||||||
class="qrcode__image"
|
<qrcode-vue
|
||||||
:src="custom.logo"
|
ref="qrCode"
|
||||||
alt="qrcode icon"
|
:value="value"
|
||||||
/>
|
:margin="margin"
|
||||||
|
:size="size"
|
||||||
|
level="Q"
|
||||||
|
render-as="svg"
|
||||||
|
class="rounded-borders q-mb-sm"
|
||||||
|
>
|
||||||
|
<q-tooltip :model-value="href === '' ? value : href"></q-tooltip>
|
||||||
|
</qrcode-vue>
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
:src="logo"
|
||||||
|
class="qrcode__image"
|
||||||
|
alt="qrcode icon"
|
||||||
|
style="pointer-events: none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showButtons"
|
||||||
|
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-if="nfc && nfcSupported"
|
||||||
|
:disabled="nfcTagWriting"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
class="text-grey"
|
||||||
|
icon="nfc"
|
||||||
|
@click="writeNfcTag"
|
||||||
|
>
|
||||||
|
<q-tooltip>Write NFC Tag</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense class="text-grey" icon="download" @click="downloadSVG">
|
||||||
|
<q-tooltip>Download SVG</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
class="text-grey"
|
||||||
|
@click="copyText(value)"
|
||||||
|
icon="content_copy"
|
||||||
|
>
|
||||||
|
<q-tooltip>Copy</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="lnbits-qrcode-lnurl">
|
||||||
|
<div class="qrcode_lnurl__wrapper">
|
||||||
|
<q-tabs
|
||||||
|
v-model="tab"
|
||||||
|
dense
|
||||||
|
class="text-grey"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="justify"
|
||||||
|
inline-label
|
||||||
|
>
|
||||||
|
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
|
||||||
|
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
<lnbits-qrcode :value="lnurl" nfc="true"></lnbits-qrcode>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1020,42 +1086,22 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
|
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
|
||||||
class="text-center q-my-lg"
|
|
||||||
>
|
>
|
||||||
<div v-if="props.row.extra.fiat_payment_request">
|
<div v-if="props.row.extra.fiat_payment_request">
|
||||||
<a
|
<lnbits-qrcode
|
||||||
|
:value="props.row.extra.fiat_payment_request"
|
||||||
:href="props.row.extra.fiat_payment_request"
|
:href="props.row.extra.fiat_payment_request"
|
||||||
target="_blank"
|
:show-buttons="false"
|
||||||
>
|
></lnbits-qrcode>
|
||||||
<lnbits-qrcode
|
|
||||||
:value="props.row.extra.fiat_payment_request"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<a :href="'lightning:' + props.row.bolt11">
|
<lnbits-qrcode
|
||||||
<lnbits-qrcode
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
:href="'lightning:' + props.row.bolt11"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
<div class="row q-mt-md">
|
||||||
<q-card-section>
|
|
||||||
<div class="row q-gutter-x-sm">
|
|
||||||
<q-btn
|
|
||||||
v-if="
|
|
||||||
props.row.isIn && props.row.isPending && props.row.bolt11
|
|
||||||
"
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
@click="
|
|
||||||
copyText(
|
|
||||||
props.row.extra.fiat_payment_request || props.row.bolt11
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:label="$t('copy_invoice')"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
@@ -1172,9 +1218,13 @@
|
|||||||
:endpoint="endpoint"
|
:endpoint="endpoint"
|
||||||
>
|
>
|
||||||
<template v-slot:actions>
|
<template v-slot:actions>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
<q-btn
|
||||||
>Close</q-btn
|
v-close-popup
|
||||||
>
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('close')"
|
||||||
|
></q-btn>
|
||||||
</template>
|
</template>
|
||||||
</lnbits-extension-settings-form>
|
</lnbits-extension-settings-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -1258,10 +1308,10 @@
|
|||||||
<q-dialog v-model="showQRDialog">
|
<q-dialog v-model="showQRDialog">
|
||||||
<q-card class="q-pa-md">
|
<q-card class="q-pa-md">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<lnbits-qrcode :value="qrValue" :size="200"></lnbits-qrcode>
|
<lnbits-qrcode :value="qrValue"></lnbits-qrcode>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Close" v-close-popup />
|
<q-btn flat :label="$t('close')" v-close-popup />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
@@ -1620,3 +1670,189 @@
|
|||||||
<q-separator class="col q-ml-sm"></q-separator>
|
<q-separator class="col q-ml-sm"></q-separator>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="lnbits-data-fields">
|
||||||
|
<q-table
|
||||||
|
:rows="fields"
|
||||||
|
row-key="name"
|
||||||
|
:columns="fieldsTable.columns"
|
||||||
|
v-model:pagination="fieldsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:bottom-row>
|
||||||
|
<q-tr>
|
||||||
|
<q-td auto-width></q-td>
|
||||||
|
<q-td colspan="100%">
|
||||||
|
<q-btn
|
||||||
|
@click="addField"
|
||||||
|
icon="add"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
class="q-ml-xs"
|
||||||
|
:label="$t('add_field')"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<span v-text="col.label"></span>
|
||||||
|
<q-icon
|
||||||
|
v-if="col.name == 'optional'"
|
||||||
|
name="info"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The field is optional. The field can be left blank by the
|
||||||
|
user.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The UI form will not require this field to be filled out.
|
||||||
|
</li>
|
||||||
|
<li>The DB table will allow NULL values for this field.</li>
|
||||||
|
<li>Non optional fields must be filled out.</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="col.name == 'editable'"
|
||||||
|
name="info"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul>
|
||||||
|
<li>The UI form will allow the field to be edited.</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="col.name == 'sortable'"
|
||||||
|
name="info"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul>
|
||||||
|
<li>In the UI Table a column will be created for the field.</li>
|
||||||
|
<li>The UI Table column will be sortable.</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else-if="col.name == 'searchable'"
|
||||||
|
name="info"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The free text search will include this field when searching.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td>
|
||||||
|
<q-btn
|
||||||
|
v-if="props.row.readonly !== true"
|
||||||
|
@click="removeField(props.row)"
|
||||||
|
round
|
||||||
|
icon="delete"
|
||||||
|
size="sm"
|
||||||
|
color="negative"
|
||||||
|
class="q-ml-xs"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td full-width>
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
v-model="props.row.name"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="props.row.type"
|
||||||
|
:options="fieldTypes"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
></q-select>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-input dense filled v-model="props.row.label" type="text">
|
||||||
|
</q-input>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-input dense filled v-model="props.row.hint" type="text"> </q-input>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-toggle
|
||||||
|
v-model="props.row.optional"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-if="!hideAdvanced">
|
||||||
|
<q-toggle
|
||||||
|
v-if="props.row.type !== 'json'"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
v-model="props.row.editable"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-if="!hideAdvanced">
|
||||||
|
<q-toggle
|
||||||
|
v-if="props.row.type !== 'json'"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
v-model="props.row.sortable"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-if="!hideAdvanced">
|
||||||
|
<q-toggle
|
||||||
|
v-if="props.row.type !== 'json'"
|
||||||
|
:readonly="props.row.readonly === true"
|
||||||
|
v-model="props.row.searchable"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
<q-tr v-if="props.row.type === 'json'" :props="props">
|
||||||
|
<q-td></q-td>
|
||||||
|
<q-td></q-td>
|
||||||
|
<q-td colspan="100%">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="props.row.fields"
|
||||||
|
:hide-advanced="true"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user