Compare commits

..
Author SHA1 Message Date
ArcandGitHub f8069c531d Update installation.md 2025-10-08 16:33:04 +01:00
Vlad StanandGitHub a9374d509c fix: hide payment details for failed payments (#3393) 2025-10-08 14:43:40 +02:00
Vlad StanandGitHub 3e72788fc8 chore: clean prin statement (#3391) 2025-10-07 14:39:24 +03:00
f48d24aca8 feat: show badge if an extension has paid releases (#3385)
Co-authored-by: dni  <office@dnilabs.com>
2025-10-06 17:06:18 +01:00
015262c9b3 chore: qr button tweak (quasar classes should always be used) (#3351)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:56:12 +02:00
e5e481f836 fix: correct timezone handling for transaction timestamps (#3373)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:53:03 +02:00
6c32ebf7e6 fix: remove uneven padding from Repo button (#3374)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-02 13:12:08 +03:00
ArcandGitHub 7c88702754 docs: updated nix install (#3361) 2025-09-26 09:28:28 +03:00
PatMulliganandGitHub 210b8a7c18 FIX: Flake and Module for NixOS deployment (#3363) 2025-09-26 07:47:02 +02:00
Vlad StanandGitHub 4f9a5090c2 [feat] Extension Builder (#3339) 2025-09-25 18:33:33 +03:00
Hezron KaraniandGitHub c05122e5fb chore: reorder .env for a common block for .env only settings (#3350) 2025-09-17 21:16:54 +01:00
ArcandGitHub c054e47913 Updated to uv (#3360) 2025-09-13 15:23:05 +01:00
dni ⚡andGitHub 92d3269a85 chore: update to v1.3.0-rc8 (#3359) 2025-09-12 14:29:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
183b84c167 build(deps): bump axios from 1.8.2 to 1.12.0 (#3357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-09-12 12:53:27 +01:00
60f50a71a2 feat: Adds stripe tap to pay flow for TPoS running on Android PoS/phone devices (#3334)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-09-12 12:50:24 +01:00
dni ⚡andGitHub c3252ce4dc chore: update to version v1.3.0-rc7 (#3356) 2025-09-12 14:19:50 +03:00
dni ⚡andGitHub c312d70e63 fix: properly check outgoing payments in phoenixd (#3355) 2025-09-12 11:26:32 +02:00
dni ⚡andGitHub 36fc911b88 fix: js error on qrcode component (#3354) 2025-09-10 16:30:18 +03:00
dni ⚡andGitHub 672a5b3a4d fix: pay with nfc endpoint + add perform_withdraw (#3352) 2025-09-10 14:52:47 +02:00
Vlad StanandGitHub c0b33560bb feat: add search label (#3353) 2025-09-10 15:13:15 +03:00
dni ⚡andGitHub 15b6b1d512 fix: also replace http:// urls in <lnbits-qrcode-lnurl> (#3346) 2025-09-04 07:00:15 +02:00
dni ⚡andGitHub 67c92a79cf chore: update lnurl to v0.8.2 (#3347) 2025-09-03 17:17:48 +03:00
dni ⚡andGitHub 0d4751d6e0 fix: catch error response for lnurl pay (#3344) 2025-09-03 12:36:20 +02:00
dni ⚡andGitHub 213bde61a8 CI: add poetry lock file check job and update poetry lock (#3343) 2025-09-02 19:50:59 +03:00
dni ⚡andGitHub 59553a6e1e chore: update to v1.3.0-rc6 (#3342) 2025-09-02 17:50:20 +02:00
918add5e6e fix: start background tasks when activating extensions at runtime (#3333)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-09-02 17:39:50 +02:00
dni ⚡andGitHub 2375f7bcf8 feat: add copy, download and write NFC to qrcode components (#3335) 2025-09-02 14:48:29 +02:00
dni ⚡andGitHub 5021570f68 feat: add has_connection, listen and receive_queue to websocket_manager (#3330) 2025-09-02 10:02:22 +02:00
dni ⚡andGitHub 0d91d5b5be chore: update to lnurl v0.8.1 (#3341) 2025-09-02 10:01:37 +02:00
Vlad StanandGitHub 49f02a5b77 chore: ignore .venv dir (#3338) 2025-09-01 15:10:56 +03:00
dni ⚡andGitHub 95b9dabb60 fix: docker for boltz-client broke on rc release (#3329) 2025-08-22 20:17:01 +02:00
dni ⚡andGitHub bc0087ec36 ci: fix pip cache is not needed for make (#3331) 2025-08-22 16:04:01 +02:00
66 changed files with 4484 additions and 798 deletions
+88 -59
View File
@@ -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
-1
View File
@@ -21,7 +21,6 @@ runs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
cache: "pip"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@v6
+3
View File
@@ -53,3 +53,6 @@ jobs:
with: with:
make: checkbundle make: checkbundle
npm: true npm: true
poetry:
uses: ./.github/workflows/poetry.yml
+48 -15
View File
@@ -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:
+24
View File
@@ -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
+1
View File
@@ -61,3 +61,4 @@ dist
# jetbrains # jetbrains
.idea .idea
.venv
+5 -5
View File
@@ -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]
+3
View File
@@ -7,6 +7,9 @@ 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)
RUN uv sync --all-extras RUN uv sync --all-extras
+13 -5
View File
@@ -15,8 +15,8 @@ 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
``` ```
@@ -132,10 +132,18 @@ Now visit `0.0.0.0:5000` to make a super-user account.
```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 <(c&url -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
@@ -160,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
+2 -1
View File
@@ -139,9 +139,10 @@
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars)); replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
}; };
# System-specific nixos modules to avoid circular dependency
nixosModules.default = { pkgs, lib, config, ... }: { nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ]; imports = [ "${./nix/modules/lnbits-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ]; nixpkgs.overlays = [ self.overlays.${system}.default ];
}; };
checks = { }; checks = { };
+2
View File
@@ -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)
+42 -14
View File
@@ -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:
+460
View File
@@ -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
+2 -1
View File
@@ -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",
+6 -1
View File
@@ -21,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()
@@ -35,6 +37,7 @@ 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
if not skip_download:
await ext_info.download_archive() await ext_info.download_archive()
ext_info.extract_archive() ext_info.extract_archive()
@@ -73,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:
+543
View File
@@ -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
+29
View File
@@ -7,7 +7,10 @@ from lnurl import (
LnurlPayActionResponse, LnurlPayActionResponse,
LnurlPayResponse, LnurlPayResponse,
LnurlResponseException, LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
execute_pay_request, execute_pay_request,
execute_withdraw,
handle, handle,
) )
from loguru import logger from loguru import logger
@@ -15,10 +18,36 @@ from loguru import logger
from lnbits.core.crud import update_wallet from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink 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 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)
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( async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None lnurl: str, amount_msat: int, comment: str | None = None
) -> str: ) -> str:
+5 -6
View File
@@ -277,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
@@ -288,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}),
) )
+2
View File
@@ -139,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
+50 -12
View File
@@ -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)
@@ -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>
+2 -4
View File
@@ -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>
+48 -10
View File
@@ -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">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode <lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()" :value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedRelease.paymentRequest"
></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
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<lnbits-qrcode <lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()" :value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
></lnbits-qrcode> ></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>&nbsp;
(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>&nbsp;
(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
>&nbsp; (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
>&nbsp; (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
>&nbsp; (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
>&nbsp; (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
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (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
>&nbsp; (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 %}
+22 -41
View File
@@ -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,13 +267,15 @@
: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>
@@ -411,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>
@@ -862,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>
@@ -898,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 -8
View File
@@ -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)
+164
View File
@@ -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.")
+28 -1
View File
@@ -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
+89
View File
@@ -1,4 +1,6 @@
from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
from typing import Annotated from typing import Annotated
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
@@ -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,
+2 -41
View File
@@ -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,7 +18,7 @@ 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, LnurlScan from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@@ -138,38 +134,3 @@ async def api_payments_pay_lnurl(
) )
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:
if not lnurl_data.lnurl_w.lud17:
return LnurlErrorResponse(reason="LNURL-withdraw lud17 not provided.")
try:
url = lnurl_data.lnurl_w.lud17
res = await lnurl_handle(url, user_agent=settings.user_agent, timeout=10)
except (LnurlResponseException, Exception) as 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
+25
View File
@@ -19,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,
@@ -29,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
@@ -59,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,
@@ -293,7 +296,9 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
if payment.failed: if payment.failed:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "status": "failed", "details": payment} return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
try: try:
status = await payment.check_status() status = await payment.check_status()
@@ -359,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.")
+7 -20
View File
@@ -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}
+2 -1
View File
@@ -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
+220 -59
View File
@@ -2,10 +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 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
@@ -21,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"""
@@ -30,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}",
@@ -60,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,54 +108,25 @@ class StripeWallet(FiatProvider):
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,
) -> 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
) )
r.raise_for_status() if opts.fiat_method == "terminal":
data = r.json() return await self._create_terminal_invoice(
amount_cents, currency, payment_hash, opts
session_id = data.get("id")
if not session_id:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing session id'"
)
payment_request = data.get("url")
if not payment_request:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing payment URL'"
) )
return FiatInvoiceResponse( return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=payment_request ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
)
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:
@@ -131,26 +134,21 @@ class StripeWallet(FiatProvider):
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}",
) if stripe_id.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
r.raise_for_status() r.raise_for_status()
return self._status_from_checkout_session(r.json())
data = r.json() if stripe_id.startswith("pi_"):
payment_status = data.get("payment_status") r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
if not payment_status: r.raise_for_status()
return self._status_from_payment_intent(r.json())
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
if payment_status == "paid":
# todo: handle fee
return FiatPaymentSuccessStatus()
expires_at = data.get("expires_at")
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and expires_at < _24_hours_ago.timestamp():
# be defensive: add a 24 hour buffer
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
except Exception as exc: except Exception as exc:
logger.debug(f"Error getting invoice status: {exc}") logger.debug(f"Error getting invoice status: {exc}")
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
@@ -167,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)]
+30 -1
View File
@@ -53,7 +53,12 @@ def static_url_for(static: str, path: str) -> str:
def template_renderer(additional_folders: list | None = 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)
@@ -368,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
+8
View File
@@ -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):
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -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;
+16 -1
View File
@@ -115,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',
@@ -163,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',
@@ -419,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}',
@@ -488,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',
@@ -652,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'
} }
+4 -1
View File
@@ -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
+5 -4
View File
@@ -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
+115
View File
@@ -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
}
)
}
}
})
@@ -24,8 +24,12 @@ window.app.component('lnbits-qrcode-lnurl', {
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes) const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}` this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') { } 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.lnurl = this.url.replace('https://', this.prefix + '://')
} }
}
this.$emit('update:lnurl', this.lnurl) this.$emit('update:lnurl', this.lnurl)
} }
}, },
+113 -9
View File
@@ -9,19 +9,123 @@ window.app.component('lnbits-qrcode', {
type: String, type: String,
required: true required: true
}, },
options: Object 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() { data() {
return { return {
custom: { nfcTagWriting: false,
margin: 3, nfcSupported: typeof NDEFReader != 'undefined'
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
} }
}, },
created() { methods: {
this.custom = {...this.custom, ...this.options} 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')
} }
}) })
+30 -29
View File
@@ -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, {})
+350
View File
@@ -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]
}
+9
View File
@@ -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',
+1 -1
View File
@@ -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: `
+1 -1
View File
@@ -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) +
+10 -11
View File
@@ -45,7 +45,6 @@ window.WalletPageLogic = {
payment_hash: null payment_hash: null
} }
}, },
invoiceQrCode: '',
disclaimerDialog: { disclaimerDialog: {
show: false, show: false,
location: window.location location: window.location
@@ -180,7 +179,7 @@ window.WalletPageLogic = {
methods: { methods: {
dateFromNow(unix) { dateFromNow(unix) {
const date = new Date(unix * 1000) const date = new Date(unix * 1000)
return moment.utc(date).fromNow() return moment.utc(date).local().fromNow()
}, },
formatFiatAmount(amount, currency) { formatFiatAmount(amount, currency) {
this.update.currency = currency this.update.currency = currency
@@ -313,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)
@@ -484,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
} }
@@ -583,6 +581,7 @@ window.WalletPageLogic = {
} }
} }
}) })
.catch(LNbits.utils.notifyApiError)
}, },
authLnurl() { authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({ const dismissAuthMsg = Quasar.Notify.create({
+6
View File
@@ -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 {
+1
View File
@@ -42,6 +42,7 @@
"js/components/lnbits-qrcode-lnurl.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"
+255 -133
View File
@@ -1,4 +1,4 @@
/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */ /*! Axios v1.12.0 Copyright (c) 2025 Matt Zabriskie and contributors */
(function (global, factory) { (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) : typeof define === 'function' && define.amd ? define(factory) :
@@ -585,9 +585,6 @@
function _slicedToArray(arr, i) { function _slicedToArray(arr, i) {
return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest();
} }
function _toArray(arr) {
return _arrayWithHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableRest();
}
function _toConsumableArray(arr) { function _toConsumableArray(arr) {
return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread();
} }
@@ -681,6 +678,8 @@
var toString = Object.prototype.toString; var toString = Object.prototype.toString;
var getPrototypeOf = Object.getPrototypeOf; var getPrototypeOf = Object.getPrototypeOf;
var iterator = Symbol.iterator,
toStringTag = Symbol.toStringTag;
var kindOf = function (cache) { var kindOf = function (cache) {
return function (thing) { return function (thing) {
var str = toString.call(thing); var str = toString.call(thing);
@@ -725,7 +724,7 @@
* @returns {boolean} True if value is a Buffer, otherwise false * @returns {boolean} True if value is a Buffer, otherwise false
*/ */
function isBuffer(val) { function isBuffer(val) {
return val !== null && !isUndefined(val) && val.constructor !== null && !isUndefined(val.constructor) && isFunction(val.constructor.isBuffer) && val.constructor.isBuffer(val); return val !== null && !isUndefined(val) && val.constructor !== null && !isUndefined(val.constructor) && isFunction$1(val.constructor.isBuffer) && val.constructor.isBuffer(val);
} }
/** /**
@@ -769,7 +768,7 @@
* @param {*} val The value to test * @param {*} val The value to test
* @returns {boolean} True if value is a Function, otherwise false * @returns {boolean} True if value is a Function, otherwise false
*/ */
var isFunction = typeOfTest('function'); var isFunction$1 = typeOfTest('function');
/** /**
* Determine if a value is a Number * Determine if a value is a Number
@@ -813,7 +812,27 @@
return false; return false;
} }
var prototype = getPrototypeOf(val); var prototype = getPrototypeOf(val);
return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in val) && !(Symbol.iterator in val); return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(toStringTag in val) && !(iterator in val);
};
/**
* Determine if a value is an empty object (safely handles Buffers)
*
* @param {*} val The value to test
*
* @returns {boolean} True if value is an empty object, otherwise false
*/
var isEmptyObject = function isEmptyObject(val) {
// Early return for non-objects or Buffers to prevent RangeError
if (!isObject(val) || isBuffer(val)) {
return false;
}
try {
return Object.keys(val).length === 0 && Object.getPrototypeOf(val) === Object.prototype;
} catch (e) {
// Fallback for any other objects that might cause RangeError with Object.keys()
return false;
}
}; };
/** /**
@@ -860,7 +879,7 @@
* @returns {boolean} True if value is a Stream, otherwise false * @returns {boolean} True if value is a Stream, otherwise false
*/ */
var isStream = function isStream(val) { var isStream = function isStream(val) {
return isObject(val) && isFunction(val.pipe); return isObject(val) && isFunction$1(val.pipe);
}; };
/** /**
@@ -872,9 +891,9 @@
*/ */
var isFormData = function isFormData(thing) { var isFormData = function isFormData(thing) {
var kind; var kind;
return thing && (typeof FormData === 'function' && thing instanceof FormData || isFunction(thing.append) && ((kind = kindOf(thing)) === 'formdata' || return thing && (typeof FormData === 'function' && thing instanceof FormData || isFunction$1(thing.append) && ((kind = kindOf(thing)) === 'formdata' ||
// detect form-data instance // detect form-data instance
kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]')); kind === 'object' && isFunction$1(thing.toString) && thing.toString() === '[object FormData]'));
}; };
/** /**
@@ -940,6 +959,11 @@
fn.call(null, obj[i], i, obj); fn.call(null, obj[i], i, obj);
} }
} else { } else {
// Buffer check
if (isBuffer(obj)) {
return;
}
// Iterate over object keys // Iterate over object keys
var keys = allOwnKeys ? Object.getOwnPropertyNames(obj) : Object.keys(obj); var keys = allOwnKeys ? Object.getOwnPropertyNames(obj) : Object.keys(obj);
var len = keys.length; var len = keys.length;
@@ -951,6 +975,9 @@
} }
} }
function findKey(obj, key) { function findKey(obj, key) {
if (isBuffer(obj)) {
return null;
}
key = key.toLowerCase(); key = key.toLowerCase();
var keys = Object.keys(obj); var keys = Object.keys(obj);
var i = keys.length; var i = keys.length;
@@ -993,7 +1020,8 @@
function merge( /* obj1, obj2, obj3, ... */ function merge( /* obj1, obj2, obj3, ... */
) { ) {
var _ref2 = isContextDefined(this) && this || {}, var _ref2 = isContextDefined(this) && this || {},
caseless = _ref2.caseless; caseless = _ref2.caseless,
skipUndefined = _ref2.skipUndefined;
var result = {}; var result = {};
var assignValue = function assignValue(val, key) { var assignValue = function assignValue(val, key) {
var targetKey = caseless && findKey(result, key) || key; var targetKey = caseless && findKey(result, key) || key;
@@ -1004,8 +1032,10 @@
} else if (isArray(val)) { } else if (isArray(val)) {
result[targetKey] = val.slice(); result[targetKey] = val.slice();
} else { } else {
if (!skipUndefined || !isUndefined(val)) {
result[targetKey] = val; result[targetKey] = val;
} }
}
}; };
for (var i = 0, l = arguments.length; i < l; i++) { for (var i = 0, l = arguments.length; i < l; i++) {
arguments[i] && forEach(arguments[i], assignValue); arguments[i] && forEach(arguments[i], assignValue);
@@ -1027,7 +1057,7 @@
var _ref3 = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}, var _ref3 = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {},
allOwnKeys = _ref3.allOwnKeys; allOwnKeys = _ref3.allOwnKeys;
forEach(b, function (val, key) { forEach(b, function (val, key) {
if (thisArg && isFunction(val)) { if (thisArg && isFunction$1(val)) {
a[key] = bind(val, thisArg); a[key] = bind(val, thisArg);
} else { } else {
a[key] = val; a[key] = val;
@@ -1165,10 +1195,10 @@
* @returns {void} * @returns {void}
*/ */
var forEachEntry = function forEachEntry(obj, fn) { var forEachEntry = function forEachEntry(obj, fn) {
var generator = obj && obj[Symbol.iterator]; var generator = obj && obj[iterator];
var iterator = generator.call(obj); var _iterator = generator.call(obj);
var result; var result;
while ((result = iterator.next()) && !result.done) { while ((result = _iterator.next()) && !result.done) {
var pair = result.value; var pair = result.value;
fn.call(obj, pair[0], pair[1]); fn.call(obj, pair[0], pair[1]);
} }
@@ -1235,11 +1265,11 @@
var freezeMethods = function freezeMethods(obj) { var freezeMethods = function freezeMethods(obj) {
reduceDescriptors(obj, function (descriptor, name) { reduceDescriptors(obj, function (descriptor, name) {
// skip restricted props in strict mode // skip restricted props in strict mode
if (isFunction(obj) && ['arguments', 'caller', 'callee'].indexOf(name) !== -1) { if (isFunction$1(obj) && ['arguments', 'caller', 'callee'].indexOf(name) !== -1) {
return false; return false;
} }
var value = obj[name]; var value = obj[name];
if (!isFunction(value)) return; if (!isFunction$1(value)) return;
descriptor.enumerable = false; descriptor.enumerable = false;
if ('writable' in descriptor) { if ('writable' in descriptor) {
descriptor.writable = false; descriptor.writable = false;
@@ -1275,7 +1305,7 @@
* @returns {boolean} * @returns {boolean}
*/ */
function isSpecCompliantForm(thing) { function isSpecCompliantForm(thing) {
return !!(thing && isFunction(thing.append) && thing[Symbol.toStringTag] === 'FormData' && thing[Symbol.iterator]); return !!(thing && isFunction$1(thing.append) && thing[toStringTag] === 'FormData' && thing[iterator]);
} }
var toJSONObject = function toJSONObject(obj) { var toJSONObject = function toJSONObject(obj) {
var stack = new Array(10); var stack = new Array(10);
@@ -1284,6 +1314,11 @@
if (stack.indexOf(source) >= 0) { if (stack.indexOf(source) >= 0) {
return; return;
} }
//Buffer check
if (isBuffer(source)) {
return source;
}
if (!('toJSON' in source)) { if (!('toJSON' in source)) {
stack[i] = source; stack[i] = source;
var target = isArray(source) ? [] : {}; var target = isArray(source) ? [] : {};
@@ -1301,7 +1336,7 @@
}; };
var isAsyncFn = kindOfTest('AsyncFunction'); var isAsyncFn = kindOfTest('AsyncFunction');
var isThenable = function isThenable(thing) { var isThenable = function isThenable(thing) {
return thing && (isObject(thing) || isFunction(thing)) && isFunction(thing.then) && isFunction(thing["catch"]); return thing && (isObject(thing) || isFunction$1(thing)) && isFunction$1(thing.then) && isFunction$1(thing["catch"]);
}; };
// original code // original code
@@ -1326,11 +1361,14 @@
}("axios@".concat(Math.random()), []) : function (cb) { }("axios@".concat(Math.random()), []) : function (cb) {
return setTimeout(cb); return setTimeout(cb);
}; };
}(typeof setImmediate === 'function', isFunction(_global.postMessage)); }(typeof setImmediate === 'function', isFunction$1(_global.postMessage));
var asap = typeof queueMicrotask !== 'undefined' ? queueMicrotask.bind(_global) : typeof process !== 'undefined' && process.nextTick || _setImmediate; var asap = typeof queueMicrotask !== 'undefined' ? queueMicrotask.bind(_global) : typeof process !== 'undefined' && process.nextTick || _setImmediate;
// ********************* // *********************
var isIterable = function isIterable(thing) {
return thing != null && isFunction$1(thing[iterator]);
};
var utils$1 = { var utils$1 = {
isArray: isArray, isArray: isArray,
isArrayBuffer: isArrayBuffer, isArrayBuffer: isArrayBuffer,
@@ -1342,6 +1380,7 @@
isBoolean: isBoolean, isBoolean: isBoolean,
isObject: isObject, isObject: isObject,
isPlainObject: isPlainObject, isPlainObject: isPlainObject,
isEmptyObject: isEmptyObject,
isReadableStream: isReadableStream, isReadableStream: isReadableStream,
isRequest: isRequest, isRequest: isRequest,
isResponse: isResponse, isResponse: isResponse,
@@ -1351,7 +1390,7 @@
isFile: isFile, isFile: isFile,
isBlob: isBlob, isBlob: isBlob,
isRegExp: isRegExp, isRegExp: isRegExp,
isFunction: isFunction, isFunction: isFunction$1,
isStream: isStream, isStream: isStream,
isURLSearchParams: isURLSearchParams, isURLSearchParams: isURLSearchParams,
isTypedArray: isTypedArray, isTypedArray: isTypedArray,
@@ -1387,7 +1426,8 @@
isAsyncFn: isAsyncFn, isAsyncFn: isAsyncFn,
isThenable: isThenable, isThenable: isThenable,
setImmediate: _setImmediate, setImmediate: _setImmediate,
asap: asap asap: asap,
isIterable: isIterable
}; };
/** /**
@@ -1461,9 +1501,20 @@
}, function (prop) { }, function (prop) {
return prop !== 'isAxiosError'; return prop !== 'isAxiosError';
}); });
AxiosError.call(axiosError, error.message, code, config, request, response); var msg = error && error.message ? error.message : 'Error';
axiosError.cause = error;
axiosError.name = error.name; // Prefer explicit code; otherwise copy the low-level error's code (e.g. ECONNREFUSED)
var errCode = code == null && error ? error.code : code;
AxiosError.call(axiosError, msg, errCode, config, request, response);
// Chain the original error on the standard field; non-enumerable to avoid JSON noise
if (error && axiosError.cause == null) {
Object.defineProperty(axiosError, 'cause', {
value: error,
configurable: true
});
}
axiosError.name = error && error.name || 'Error';
customProps && Object.assign(axiosError, customProps); customProps && Object.assign(axiosError, customProps);
return axiosError; return axiosError;
}; };
@@ -1580,6 +1631,9 @@
if (utils$1.isDate(value)) { if (utils$1.isDate(value)) {
return value.toISOString(); return value.toISOString();
} }
if (utils$1.isBoolean(value)) {
return value.toString();
}
if (!useBlob && utils$1.isBlob(value)) { if (!useBlob && utils$1.isBlob(value)) {
throw new AxiosError('Blob is not supported. Use a Buffer instead.'); throw new AxiosError('Blob is not supported. Use a Buffer instead.');
} }
@@ -1708,7 +1762,7 @@
* @returns {string} The encoded value. * @returns {string} The encoded value.
*/ */
function encode(val) { function encode(val) {
return encodeURIComponent(val).replace(/%3A/gi, ':').replace(/%24/g, '$').replace(/%2C/gi, ',').replace(/%20/g, '+').replace(/%5B/gi, '[').replace(/%5D/gi, ']'); return encodeURIComponent(val).replace(/%3A/gi, ':').replace(/%24/g, '$').replace(/%2C/gi, ',').replace(/%20/g, '+');
} }
/** /**
@@ -1898,7 +1952,7 @@
var platform = _objectSpread2(_objectSpread2({}, utils), platform$1); var platform = _objectSpread2(_objectSpread2({}, utils), platform$1);
function toURLEncodedForm(data, options) { function toURLEncodedForm(data, options) {
return toFormData(data, new platform.classes.URLSearchParams(), Object.assign({ return toFormData(data, new platform.classes.URLSearchParams(), _objectSpread2({
visitor: function visitor(value, key, path, helpers) { visitor: function visitor(value, key, path, helpers) {
if (platform.isNode && utils$1.isBuffer(value)) { if (platform.isNode && utils$1.isBuffer(value)) {
this.append(key, value.toString('base64')); this.append(key, value.toString('base64'));
@@ -2063,7 +2117,7 @@
var silentJSONParsing = transitional && transitional.silentJSONParsing; var silentJSONParsing = transitional && transitional.silentJSONParsing;
var strictJSONParsing = !silentJSONParsing && JSONRequested; var strictJSONParsing = !silentJSONParsing && JSONRequested;
try { try {
return JSON.parse(data); return JSON.parse(data, this.parseReviver);
} catch (e) { } catch (e) {
if (strictJSONParsing) { if (strictJSONParsing) {
if (e.name === 'SyntaxError') { if (e.name === 'SyntaxError') {
@@ -2227,21 +2281,26 @@
setHeaders(header, valueOrRewrite); setHeaders(header, valueOrRewrite);
} else if (utils$1.isString(header) && (header = header.trim()) && !isValidHeaderName(header)) { } else if (utils$1.isString(header) && (header = header.trim()) && !isValidHeaderName(header)) {
setHeaders(parseHeaders(header), valueOrRewrite); setHeaders(parseHeaders(header), valueOrRewrite);
} else if (utils$1.isHeaders(header)) { } else if (utils$1.isObject(header) && utils$1.isIterable(header)) {
var _iterator = _createForOfIteratorHelper(header.entries()), var obj = {},
dest,
key;
var _iterator = _createForOfIteratorHelper(header),
_step; _step;
try { try {
for (_iterator.s(); !(_step = _iterator.n()).done;) { for (_iterator.s(); !(_step = _iterator.n()).done;) {
var _step$value = _slicedToArray(_step.value, 2), var entry = _step.value;
key = _step$value[0], if (!utils$1.isArray(entry)) {
value = _step$value[1]; throw TypeError('Object iterator must return a key-value pair');
setHeader(value, key, rewrite); }
obj[key = entry[0]] = (dest = obj[key]) ? utils$1.isArray(dest) ? [].concat(_toConsumableArray(dest), [entry[1]]) : [dest, entry[1]] : entry[1];
} }
} catch (err) { } catch (err) {
_iterator.e(err); _iterator.e(err);
} finally { } finally {
_iterator.f(); _iterator.f();
} }
setHeaders(obj, valueOrRewrite);
} else { } else {
header != null && setHeader(valueOrRewrite, header, rewrite); header != null && setHeader(valueOrRewrite, header, rewrite);
} }
@@ -2372,6 +2431,11 @@
return header + ': ' + value; return header + ': ' + value;
}).join('\n'); }).join('\n');
} }
}, {
key: "getSetCookie",
value: function getSetCookie() {
return this.get("set-cookie") || [];
}
}, { }, {
key: _Symbol$toStringTag, key: _Symbol$toStringTag,
get: function get() { get: function get() {
@@ -2557,7 +2621,7 @@
clearTimeout(timer); clearTimeout(timer);
timer = null; timer = null;
} }
fn.apply(null, args); fn.apply(void 0, _toConsumableArray(args));
}; };
var throttled = function throttled() { var throttled = function throttled() {
var now = Date.now(); var now = Date.now();
@@ -2703,7 +2767,7 @@
*/ */
function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) { function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) {
var isRelativeUrl = !isAbsoluteURL(requestedURL); var isRelativeUrl = !isAbsoluteURL(requestedURL);
if (baseURL && isRelativeUrl || allowAbsoluteUrls == false) { if (baseURL && (isRelativeUrl || allowAbsoluteUrls == false)) {
return combineURLs(baseURL, requestedURL); return combineURLs(baseURL, requestedURL);
} }
return requestedURL; return requestedURL;
@@ -2805,7 +2869,7 @@
return mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true); return mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true);
} }
}; };
utils$1.forEach(Object.keys(Object.assign({}, config1, config2)), function computeConfigValue(prop) { utils$1.forEach(Object.keys(_objectSpread2(_objectSpread2({}, config1), config2)), function computeConfigValue(prop) {
var merge = mergeMap[prop] || mergeDeepProperties; var merge = mergeMap[prop] || mergeDeepProperties;
var configValue = merge(config1[prop], config2[prop], prop); var configValue = merge(config1[prop], config2[prop], prop);
utils$1.isUndefined(configValue) && merge !== mergeDirectKeys || (config[prop] = configValue); utils$1.isUndefined(configValue) && merge !== mergeDirectKeys || (config[prop] = configValue);
@@ -2822,25 +2886,28 @@
headers = newConfig.headers, headers = newConfig.headers,
auth = newConfig.auth; auth = newConfig.auth;
newConfig.headers = headers = AxiosHeaders$1.from(headers); newConfig.headers = headers = AxiosHeaders$1.from(headers);
newConfig.url = buildURL(buildFullPath(newConfig.baseURL, newConfig.url), config.params, config.paramsSerializer); newConfig.url = buildURL(buildFullPath(newConfig.baseURL, newConfig.url, newConfig.allowAbsoluteUrls), config.params, config.paramsSerializer);
// HTTP basic authentication // HTTP basic authentication
if (auth) { if (auth) {
headers.set('Authorization', 'Basic ' + btoa((auth.username || '') + ':' + (auth.password ? unescape(encodeURIComponent(auth.password)) : ''))); headers.set('Authorization', 'Basic ' + btoa((auth.username || '') + ':' + (auth.password ? unescape(encodeURIComponent(auth.password)) : '')));
} }
var contentType;
if (utils$1.isFormData(data)) { if (utils$1.isFormData(data)) {
if (platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv) { if (platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv) {
headers.setContentType(undefined); // Let the browser set it headers.setContentType(undefined); // browser handles it
} else if ((contentType = headers.getContentType()) !== false) { } else if (utils$1.isFunction(data.getHeaders)) {
// fix semicolon duplication issue for ReactNative FormData implementation // Node.js FormData (like form-data package)
var _ref = contentType ? contentType.split(';').map(function (token) { var formHeaders = data.getHeaders();
return token.trim(); // Only set safe headers to avoid overwriting security headers
}).filter(Boolean) : [], var allowedHeaders = ['content-type', 'content-length'];
_ref2 = _toArray(_ref), Object.entries(formHeaders).forEach(function (_ref) {
type = _ref2[0], var _ref2 = _slicedToArray(_ref, 2),
tokens = _ref2.slice(1); key = _ref2[0],
headers.setContentType([type || 'multipart/form-data'].concat(_toConsumableArray(tokens)).join('; ')); val = _ref2[1];
if (allowedHeaders.includes(key.toLowerCase())) {
headers.set(key, val);
}
});
} }
} }
@@ -2946,12 +3013,15 @@
}; };
// Handle low level network errors // Handle low level network errors
request.onerror = function handleError() { request.onerror = function handleError(event) {
// Real errors are hidden from us by the browser // Browsers deliver a ProgressEvent in XHR onerror
// onerror should only fire if it's a network error // (message may be empty; when present, surface it)
reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request)); // See https://developer.mozilla.org/docs/Web/API/XMLHttpRequest/error_event
var msg = event && event.message ? event.message : 'Network Error';
// Clean up request var err = new AxiosError(msg, AxiosError.ERR_NETWORK, config, request);
// attach the underlying event for consumers who want details
err.event = event || null;
reject(err);
request = null; request = null;
}; };
@@ -3280,35 +3350,21 @@
}); });
}; };
var isFetchSupported = typeof fetch === 'function' && typeof Request === 'function' && typeof Response === 'function'; var DEFAULT_CHUNK_SIZE = 64 * 1024;
var isReadableStreamSupported = isFetchSupported && typeof ReadableStream === 'function'; var isFunction = utils$1.isFunction;
var globalFetchAPI = function (_ref) {
// used only inside the fetch adapter var fetch = _ref.fetch,
var encodeText = isFetchSupported && (typeof TextEncoder === 'function' ? function (encoder) { Request = _ref.Request,
return function (str) { Response = _ref.Response;
return encoder.encode(str); return {
fetch: fetch,
Request: Request,
Response: Response
}; };
}(new TextEncoder()) : ( /*#__PURE__*/function () { }(utils$1.global);
var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(str) { var _utils$global = utils$1.global,
return _regeneratorRuntime().wrap(function _callee$(_context) { ReadableStream$1 = _utils$global.ReadableStream,
while (1) switch (_context.prev = _context.next) { TextEncoder = _utils$global.TextEncoder;
case 0:
_context.t0 = Uint8Array;
_context.next = 3;
return new Response(str).arrayBuffer();
case 3:
_context.t1 = _context.sent;
return _context.abrupt("return", new _context.t0(_context.t1));
case 5:
case "end":
return _context.stop();
}
}, _callee);
}));
return function (_x) {
return _ref.apply(this, arguments);
};
}()));
var test = function test(fn) { var test = function test(fn) {
try { try {
for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
@@ -3319,10 +3375,47 @@
return false; return false;
} }
}; };
var supportsRequestStream = isReadableStreamSupported && test(function () { var factory = function factory(env) {
var _Object$assign = Object.assign({}, globalFetchAPI, env),
fetch = _Object$assign.fetch,
Request = _Object$assign.Request,
Response = _Object$assign.Response;
var isFetchSupported = isFunction(fetch);
var isRequestSupported = isFunction(Request);
var isResponseSupported = isFunction(Response);
if (!isFetchSupported) {
return false;
}
var isReadableStreamSupported = isFetchSupported && isFunction(ReadableStream$1);
var encodeText = isFetchSupported && (typeof TextEncoder === 'function' ? function (encoder) {
return function (str) {
return encoder.encode(str);
};
}(new TextEncoder()) : ( /*#__PURE__*/function () {
var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(str) {
return _regeneratorRuntime().wrap(function _callee$(_context) {
while (1) switch (_context.prev = _context.next) {
case 0:
_context.t0 = Uint8Array;
_context.next = 3;
return new Request(str).arrayBuffer();
case 3:
_context.t1 = _context.sent;
return _context.abrupt("return", new _context.t0(_context.t1));
case 5:
case "end":
return _context.stop();
}
}, _callee);
}));
return function (_x) {
return _ref2.apply(this, arguments);
};
}()));
var supportsRequestStream = isRequestSupported && isReadableStreamSupported && test(function () {
var duplexAccessed = false; var duplexAccessed = false;
var hasContentType = new Request(platform.origin, { var hasContentType = new Request(platform.origin, {
body: new ReadableStream(), body: new ReadableStream$1(),
method: 'POST', method: 'POST',
get duplex() { get duplex() {
duplexAccessed = true; duplexAccessed = true;
@@ -3331,8 +3424,7 @@
}).headers.has('Content-Type'); }).headers.has('Content-Type');
return duplexAccessed && !hasContentType; return duplexAccessed && !hasContentType;
}); });
var DEFAULT_CHUNK_SIZE = 64 * 1024; var supportsResponseStream = isResponseSupported && isReadableStreamSupported && test(function () {
var supportsResponseStream = isReadableStreamSupported && test(function () {
return utils$1.isReadableStream(new Response('').body); return utils$1.isReadableStream(new Response('').body);
}); });
var resolvers = { var resolvers = {
@@ -3340,17 +3432,19 @@
return res.body; return res.body;
} }
}; };
isFetchSupported && function (res) { isFetchSupported && function () {
['text', 'arrayBuffer', 'blob', 'formData', 'stream'].forEach(function (type) { ['text', 'arrayBuffer', 'blob', 'formData', 'stream'].forEach(function (type) {
!resolvers[type] && (resolvers[type] = utils$1.isFunction(res[type]) ? function (res) { !resolvers[type] && (resolvers[type] = function (res, config) {
return res[type](); var method = res && res[type];
} : function (_, config) { if (method) {
return method.call(res);
}
throw new AxiosError("Response type '".concat(type, "' is not supported"), AxiosError.ERR_NOT_SUPPORT, config); throw new AxiosError("Response type '".concat(type, "' is not supported"), AxiosError.ERR_NOT_SUPPORT, config);
}); });
}); });
}(new Response()); }();
var getBodyLength = /*#__PURE__*/function () { var getBodyLength = /*#__PURE__*/function () {
var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) { var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) {
var _request; var _request;
return _regeneratorRuntime().wrap(function _callee2$(_context2) { return _regeneratorRuntime().wrap(function _callee2$(_context2) {
while (1) switch (_context2.prev = _context2.next) { while (1) switch (_context2.prev = _context2.next) {
@@ -3404,11 +3498,11 @@
}, _callee2); }, _callee2);
})); }));
return function getBodyLength(_x2) { return function getBodyLength(_x2) {
return _ref2.apply(this, arguments); return _ref3.apply(this, arguments);
}; };
}(); }();
var resolveBodyLength = /*#__PURE__*/function () { var resolveBodyLength = /*#__PURE__*/function () {
var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3(headers, body) { var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3(headers, body) {
var length; var length;
return _regeneratorRuntime().wrap(function _callee3$(_context3) { return _regeneratorRuntime().wrap(function _callee3$(_context3) {
while (1) switch (_context3.prev = _context3.next) { while (1) switch (_context3.prev = _context3.next) {
@@ -3422,35 +3516,36 @@
}, _callee3); }, _callee3);
})); }));
return function resolveBodyLength(_x3, _x4) { return function resolveBodyLength(_x3, _x4) {
return _ref3.apply(this, arguments); return _ref4.apply(this, arguments);
}; };
}(); }();
var fetchAdapter = isFetchSupported && ( /*#__PURE__*/function () { return /*#__PURE__*/function () {
var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) { var _ref5 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) {
var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, composedSignal, request, unsubscribe, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, response, isStreamResponse, options, responseContentLength, _ref5, _ref6, _onProgress, _flush, responseData; var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, composedSignal, request, unsubscribe, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, resolvedOptions, response, isStreamResponse, options, responseContentLength, _ref6, _ref7, _onProgress, _flush, responseData;
return _regeneratorRuntime().wrap(function _callee4$(_context4) { return _regeneratorRuntime().wrap(function _callee4$(_context4) {
while (1) switch (_context4.prev = _context4.next) { while (1) switch (_context4.prev = _context4.next) {
case 0: case 0:
_resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions; _resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions;
responseType = responseType ? (responseType + '').toLowerCase() : 'text'; responseType = responseType ? (responseType + '').toLowerCase() : 'text';
composedSignal = composeSignals$1([signal, cancelToken && cancelToken.toAbortSignal()], timeout); composedSignal = composeSignals$1([signal, cancelToken && cancelToken.toAbortSignal()], timeout);
request = null;
unsubscribe = composedSignal && composedSignal.unsubscribe && function () { unsubscribe = composedSignal && composedSignal.unsubscribe && function () {
composedSignal.unsubscribe(); composedSignal.unsubscribe();
}; };
_context4.prev = 4; _context4.prev = 5;
_context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head'; _context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head';
if (!_context4.t0) { if (!_context4.t0) {
_context4.next = 11; _context4.next = 12;
break; break;
} }
_context4.next = 9; _context4.next = 10;
return resolveBodyLength(headers, data); return resolveBodyLength(headers, data);
case 9: case 10:
_context4.t1 = requestContentLength = _context4.sent; _context4.t1 = requestContentLength = _context4.sent;
_context4.t0 = _context4.t1 !== 0; _context4.t0 = _context4.t1 !== 0;
case 11: case 12:
if (!_context4.t0) { if (!_context4.t0) {
_context4.next = 15; _context4.next = 16;
break; break;
} }
_request = new Request(url, { _request = new Request(url, {
@@ -3465,25 +3560,26 @@
_progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1]; _progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1];
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush); data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush);
} }
case 15: case 16:
if (!utils$1.isString(withCredentials)) { if (!utils$1.isString(withCredentials)) {
withCredentials = withCredentials ? 'include' : 'omit'; withCredentials = withCredentials ? 'include' : 'omit';
} }
// Cloudflare Workers throws when credentials are defined // Cloudflare Workers throws when credentials are defined
// see https://github.com/cloudflare/workerd/issues/902 // see https://github.com/cloudflare/workerd/issues/902
isCredentialsSupported = "credentials" in Request.prototype; isCredentialsSupported = isRequestSupported && "credentials" in Request.prototype;
request = new Request(url, _objectSpread2(_objectSpread2({}, fetchOptions), {}, { resolvedOptions = _objectSpread2(_objectSpread2({}, fetchOptions), {}, {
signal: composedSignal, signal: composedSignal,
method: method.toUpperCase(), method: method.toUpperCase(),
headers: headers.normalize().toJSON(), headers: headers.normalize().toJSON(),
body: data, body: data,
duplex: "half", duplex: "half",
credentials: isCredentialsSupported ? withCredentials : undefined credentials: isCredentialsSupported ? withCredentials : undefined
})); });
_context4.next = 20; request = isRequestSupported && new Request(url, resolvedOptions);
return fetch(request); _context4.next = 22;
case 20: return isRequestSupported ? fetch(request, fetchOptions) : fetch(url, resolvedOptions);
case 22:
response = _context4.sent; response = _context4.sent;
isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response'); isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response');
if (supportsResponseStream && (onDownloadProgress || isStreamResponse && unsubscribe)) { if (supportsResponseStream && (onDownloadProgress || isStreamResponse && unsubscribe)) {
@@ -3492,19 +3588,19 @@
options[prop] = response[prop]; options[prop] = response[prop];
}); });
responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length')); responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length'));
_ref5 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref6 = _slicedToArray(_ref5, 2), _onProgress = _ref6[0], _flush = _ref6[1]; _ref6 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref7 = _slicedToArray(_ref6, 2), _onProgress = _ref7[0], _flush = _ref7[1];
response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () { response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () {
_flush && _flush(); _flush && _flush();
unsubscribe && unsubscribe(); unsubscribe && unsubscribe();
}), options); }), options);
} }
responseType = responseType || 'text'; responseType = responseType || 'text';
_context4.next = 26; _context4.next = 28;
return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config); return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config);
case 26: case 28:
responseData = _context4.sent; responseData = _context4.sent;
!isStreamResponse && unsubscribe && unsubscribe(); !isStreamResponse && unsubscribe && unsubscribe();
_context4.next = 30; _context4.next = 32;
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
settle(resolve, reject, { settle(resolve, reject, {
data: responseData, data: responseData,
@@ -3515,36 +3611,62 @@
request: request request: request
}); });
}); });
case 30: case 32:
return _context4.abrupt("return", _context4.sent); return _context4.abrupt("return", _context4.sent);
case 33: case 35:
_context4.prev = 33; _context4.prev = 35;
_context4.t2 = _context4["catch"](4); _context4.t2 = _context4["catch"](5);
unsubscribe && unsubscribe(); unsubscribe && unsubscribe();
if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /fetch/i.test(_context4.t2.message))) { if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /Load failed|fetch/i.test(_context4.t2.message))) {
_context4.next = 38; _context4.next = 40;
break; break;
} }
throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), { throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), {
cause: _context4.t2.cause || _context4.t2 cause: _context4.t2.cause || _context4.t2
}); });
case 38: case 40:
throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request); throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request);
case 39: case 41:
case "end": case "end":
return _context4.stop(); return _context4.stop();
} }
}, _callee4, null, [[4, 33]]); }, _callee4, null, [[5, 35]]);
})); }));
return function (_x5) { return function (_x5) {
return _ref4.apply(this, arguments); return _ref5.apply(this, arguments);
}; };
}()); }();
};
var seedCache = new Map();
var getFetch = function getFetch(config) {
var env = utils$1.merge.call({
skipUndefined: true
}, globalFetchAPI, config ? config.env : null);
var fetch = env.fetch,
Request = env.Request,
Response = env.Response;
var seeds = [Request, Response, fetch];
var len = seeds.length,
i = len,
seed,
target,
map = seedCache;
while (i--) {
seed = seeds[i];
target = map.get(seed);
target === undefined && map.set(seed, target = i ? new Map() : factory(env));
map = target;
}
return target;
};
getFetch();
var knownAdapters = { var knownAdapters = {
http: httpAdapter, http: httpAdapter,
xhr: xhrAdapter, xhr: xhrAdapter,
fetch: fetchAdapter fetch: {
get: getFetch
}
}; };
utils$1.forEach(knownAdapters, function (fn, value) { utils$1.forEach(knownAdapters, function (fn, value) {
if (fn) { if (fn) {
@@ -3567,7 +3689,7 @@
return utils$1.isFunction(adapter) || adapter === null || adapter === false; return utils$1.isFunction(adapter) || adapter === null || adapter === false;
}; };
var adapters = { var adapters = {
getAdapter: function getAdapter(adapters) { getAdapter: function getAdapter(adapters, config) {
adapters = utils$1.isArray(adapters) ? adapters : [adapters]; adapters = utils$1.isArray(adapters) ? adapters : [adapters];
var _adapters = adapters, var _adapters = adapters,
length = _adapters.length; length = _adapters.length;
@@ -3584,7 +3706,7 @@
throw new AxiosError("Unknown adapter '".concat(id, "'")); throw new AxiosError("Unknown adapter '".concat(id, "'"));
} }
} }
if (adapter) { if (adapter && (utils$1.isFunction(adapter) || (adapter = adapter.get(config)))) {
break; break;
} }
rejectedReasons[id || '#' + i] = adapter; rejectedReasons[id || '#' + i] = adapter;
@@ -3636,7 +3758,7 @@
if (['post', 'put', 'patch'].indexOf(config.method) !== -1) { if (['post', 'put', 'patch'].indexOf(config.method) !== -1) {
config.headers.setContentType('application/x-www-form-urlencoded', false); config.headers.setContentType('application/x-www-form-urlencoded', false);
} }
var adapter = adapters.getAdapter(config.adapter || defaults$1.adapter); var adapter = adapters.getAdapter(config.adapter || defaults$1.adapter, config);
return adapter(config).then(function onAdapterResolution(response) { return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config); throwIfCancellationRequested(config);
@@ -3658,7 +3780,7 @@
}); });
} }
var VERSION = "1.8.2"; var VERSION = "1.12.0";
var validators$1 = {}; var validators$1 = {};
@@ -3754,7 +3876,7 @@
var Axios = /*#__PURE__*/function () { var Axios = /*#__PURE__*/function () {
function Axios(instanceConfig) { function Axios(instanceConfig) {
_classCallCheck(this, Axios); _classCallCheck(this, Axios);
this.defaults = instanceConfig; this.defaults = instanceConfig || {};
this.interceptors = { this.interceptors = {
request: new InterceptorManager$1(), request: new InterceptorManager$1(),
response: new InterceptorManager$1() response: new InterceptorManager$1()
+251 -39
View File
@@ -617,22 +617,65 @@
</template> </template>
<template id="lnbits-qrcode"> <template id="lnbits-qrcode">
<div class="qrcode__wrapper"> <div
class="qrcode__outer"
:style="`margin: 13px auto; max-width: ${maxWidth}px`"
>
<div ref="qrWrapper" class="qrcode__wrapper">
<a
:href="href"
:title="href === '' ? value : href"
@click="clickQrCode"
class="no-link full-width"
>
<qrcode-vue <qrcode-vue
ref="qrCode"
:value="value" :value="value"
:margin="margin"
:size="size"
level="Q" level="Q"
render-as="svg" render-as="svg"
:margin="custom.margin" class="rounded-borders q-mb-sm"
:size="custom.width" >
class="rounded-borders" <q-tooltip :model-value="href === '' ? value : href"></q-tooltip>
></qrcode-vue> </qrcode-vue>
</a>
<img <img
v-if="custom.logo" :src="logo"
class="qrcode__image" class="qrcode__image"
:src="custom.logo"
alt="qrcode icon" alt="qrcode icon"
style="pointer-events: none"
/> />
</div> </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>
<template id="lnbits-qrcode-lnurl"> <template id="lnbits-qrcode-lnurl">
@@ -640,17 +683,16 @@
<q-tabs <q-tabs
v-model="tab" v-model="tab"
dense dense
class="text-grey q-mb-md" class="text-grey"
active-color="primary" active-color="primary"
indicator-color="primary" indicator-color="primary"
align="justify" align="justify"
narrow-indicator
inline-label inline-label
> >
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab> <q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab> <q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs> </q-tabs>
<lnbits-qrcode :value="lnurl" class="rounded-borders"></lnbits-qrcode> <lnbits-qrcode :value="lnurl" nfc="true"></lnbits-qrcode>
</div> </div>
</template> </template>
@@ -1044,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
:href="props.row.extra.fiat_payment_request"
target="_blank"
>
<lnbits-qrcode <lnbits-qrcode
:value="props.row.extra.fiat_payment_request" :value="props.row.extra.fiat_payment_request"
:href="props.row.extra.fiat_payment_request"
:show-buttons="false"
></lnbits-qrcode> ></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"
@@ -1196,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>
@@ -1282,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>
@@ -1644,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>
+2 -6
View File
@@ -226,9 +226,7 @@ class PhoenixdWallet(Wallet):
) )
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get( r = await self.client.get(f"/payments/incoming/{checking_id}")
f"/payments/incoming/{checking_id}?all=true&limit=1000"
)
if r.is_error: if r.is_error:
if r.status_code == 404: if r.status_code == 404:
# invoice does not exist in phoenixd, so it was never paid # invoice does not exist in phoenixd, so it was never paid
@@ -260,9 +258,7 @@ class PhoenixdWallet(Wallet):
return PaymentPendingStatus() return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get( r = await self.client.get(f"/payments/outgoingbyhash/{checking_id}")
f"/payments/outgoing/{checking_id}?all=true&limit=1000"
)
if r.is_error: if r.is_error:
if r.status_code == 404: if r.status_code == 404:
# payment does not exist in phoenixd, so it was never paid # payment does not exist in phoenixd, so it was never paid
+213
View File
@@ -0,0 +1,213 @@
# LNBits NixOS Installation Guide
This guide shows how to install LNBits on a fresh NixOS system.
## Quick Start (Recommended)
Add this to your NixOS configuration (`/etc/nixos/configuration.nix`):
```nix
{ config, lib, pkgs, ... }:
let
lnbitsFlake = builtins.getFlake "github:lnbits/lnbits";
in
{
imports = [
# Import LNBits service module directly from GitHub
"${lnbitsFlake}/nix/modules/lnbits-service.nix"
];
# Enable flakes (required)
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Configure LNBits service
services.lnbits = {
enable = true;
host = "0.0.0.0"; # Listen on all interfaces
port = 5000; # Default port
openFirewall = true; # Open firewall port automatically
# Use package from the same flake (adjust system architecture as needed)
package = lnbitsFlake.packages.x86_64-linux.lnbits;
env = {
LNBITS_ADMIN_UI = "true";
# Configure your Lightning backend:
# LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
# LND_REST_ENDPOINT = "https://localhost:8080";
# LND_REST_CERT = "/path/to/tls.cert";
# LND_REST_MACAROON = "/path/to/admin.macaroon";
};
};
# Rebuild and switch
# sudo nixos-rebuild switch
}
```
> **⚠️ System Architecture Note**: The examples above use `x86_64-linux`. Replace this with your system architecture:
>
> - `x86_64-linux` - Intel/AMD 64-bit Linux
> - `aarch64-linux` - ARM 64-bit Linux (e.g., Raspberry Pi 4, Apple Silicon under Linux)
> - `x86_64-darwin` - Intel Mac
> - `aarch64-darwin` - Apple Silicon Mac
>
> You can check your system with: `nix eval --impure --raw --expr 'builtins.currentSystem'`
## Alternative: Using Your Own Flake
Create a `flake.nix` for your system configuration:
```nix
{
description = "My NixOS configuration with LNBits";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
lnbits.url = "github:lnbits/lnbits";
};
outputs = { self, nixpkgs, lnbits }: {
nixosConfigurations.myserver = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; # Adjust architecture as needed
modules = [
./hardware-configuration.nix
{
services.lnbits = {
enable = true;
host = "0.0.0.0";
port = 5000;
openFirewall = true;
package = lnbits.packages.x86_64-linux.lnbits; # Adjust architecture as needed
env = {
LNBITS_ADMIN_UI = "true";
# Add your Lightning backend configuration
};
};
}
];
};
};
}
```
Then deploy with:
```bash
sudo nixos-rebuild switch --flake .#myserver
```
## Configuration Options
### Basic Options
- `enable`: Enable the LNBits service (default: `false`)
- `host`: Host to bind to (default: `"127.0.0.1"`)
- `port`: Port to run on (default: `8231`)
- `openFirewall`: Automatically open firewall port (default: `false`)
- `user`: User to run as (default: `"lnbits"`)
- `group`: Group to run as (default: `"lnbits"`)
- `stateDir`: State directory (default: `"/var/lib/lnbits"`)
### Environment Variables
Configure LNBits through the `env` option. Common variables:
```nix
services.lnbits.env = {
# Admin UI
LNBITS_ADMIN_UI = "true";
# LND Backend Example:
# LND
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
LND_REST_ENDPOINT = "https://localhost:8080";
LND_REST_CERT = "/path/to/tls.cert";
LND_REST_MACAROON = "/path/to/admin.macaroon";
};
```
See the [LNBits documentation](https://docs.lnbits.org/guide/wallets.html) for all supported backends.
## State Directory Structure
LNBits data is stored in `/var/lib/lnbits` (default) with this structure:
```
/var/lib/lnbits/
├── data/ # Application data
│ ├── database.sqlite3 # Main database
│ ├── ext_<extension>.sqlite3 # Extension database
│ ├── images/ # Uploaded images
│ ├── logs/ # Log files
│ └── upgrades/ # Migration files
└── extensions/ # Installed extensions
```
## First Time Setup
1. **Deploy the configuration:**
```bash
sudo nixos-rebuild switch
```
2. **Check service status:**
```bash
systemctl status lnbits
```
3. **Access the web interface:**
```
http://your-server-ip:5000
```
4. **Follow the first-time setup wizard** to configure your Lightning backend and create your first wallet.
5. **Bonus** Add Reverse Proxy with generated SSL Cert
```nix
{
# Enable nginx
services.nginx = {
enable = true;
virtualHosts."lnbits.mydomain.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:5000";
proxyWebsockets = true;
};
};
};
}
```
## Troubleshooting
### Service won't start
```bash
# Check service logs
journalctl -u lnbits -f
# Check if port is available
ss -tlnp | grep 5000
```
### Can't access web interface
- Ensure `openFirewall = true` is set
- Check if the port is correct: `services.lnbits.port`
- Verify host binding: `services.lnbits.host = "0.0.0.0"`
## Further Reading
- [LNBits Documentation](https://docs.lnbits.org)
- [Lightning Wallet Configuration](https://docs.lnbits.org/guide/wallets.html)
- [LNBits Extensions](https://docs.lnbits.org/devs/extensions.html)
+8 -7
View File
@@ -35,7 +35,7 @@ in
type = types.path; type = types.path;
default = "/var/lib/lnbits"; default = "/var/lib/lnbits";
description = '' description = ''
The lnbits state directory which LNBITS_DATA_FOLDER will be set to The lnbits state directory
''; '';
}; };
host = mkOption { host = mkOption {
@@ -90,6 +90,7 @@ in
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/data 0700 ${cfg.user} ${cfg.group} - -"
]; ];
systemd.services.lnbits = { systemd.services.lnbits = {
@@ -99,18 +100,18 @@ in
after = [ "network-online.target" ]; after = [ "network-online.target" ];
environment = lib.mkMerge [ environment = lib.mkMerge [
{ {
LNBITS_DATA_FOLDER = "${cfg.stateDir}"; LNBITS_DATA_FOLDER = "${cfg.stateDir}/data";
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions"; # LNBits automatically appends '/extensions' to this path
LNBITS_PATH = "${cfg.package.src}"; LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}";
} }
cfg.env cfg.env
]; ];
serviceConfig = { serviceConfig = {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
WorkingDirectory = "${cfg.package.src}"; WorkingDirectory = "${cfg.package}/lib/python3.12/site-packages";
StateDirectory = "${cfg.stateDir}"; StateDirectory = "lnbits";
ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}"; ExecStart = "${cfg.package}/bin/lnbits --port ${toString cfg.port} --host ${cfg.host}";
Restart = "always"; Restart = "always";
PrivateTmp = true; PrivateTmp = true;
}; };
+9 -9
View File
@@ -6,7 +6,7 @@
"": { "": {
"name": "lnbits", "name": "lnbits",
"dependencies": { "dependencies": {
"axios": "^1.8.2", "axios": "^1.12.0",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.7.2",
@@ -23,7 +23,7 @@
"devDependencies": { "devDependencies": {
"concat": "^1.0.3", "concat": "^1.0.3",
"minify": "^9.2.0", "minify": "^9.2.0",
"prettier": "^3.3.3", "prettier": "^3.6.2",
"pyright": "1.1.289", "pyright": "1.1.289",
"sass": "^1.78.0" "sass": "^1.78.0"
} }
@@ -402,13 +402,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.8.2", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -1222,9 +1222,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.3.3", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
+3 -2
View File
@@ -15,12 +15,12 @@
"devDependencies": { "devDependencies": {
"concat": "^1.0.3", "concat": "^1.0.3",
"minify": "^9.2.0", "minify": "^9.2.0",
"prettier": "^3.3.3", "prettier": "^3.6.2",
"pyright": "1.1.289", "pyright": "1.1.289",
"sass": "^1.78.0" "sass": "^1.78.0"
}, },
"dependencies": { "dependencies": {
"axios": "^1.8.2", "axios": "^1.12.0",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"qrcode.vue": "^3.4.1", "qrcode.vue": "^3.4.1",
@@ -94,6 +94,7 @@
"js/components/lnbits-qrcode-lnurl.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"
Generated
+4 -4
View File
@@ -2134,14 +2134,14 @@ valkey = ["valkey (>=6)"]
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.8.0" version = "0.8.3"
description = "LNURL implementation for Python." description = "LNURL implementation for Python."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "lnurl-0.8.0-py3-none-any.whl", hash = "sha256:260be16dbcc45fd7dd937f781bd79c9fdaf38b4bb04d6e13a1820f24eac9389c"}, {file = "lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a"},
{file = "lnurl-0.8.0.tar.gz", hash = "sha256:fa970f7d343caae7435848c592dd223d7ac8120e337d2d5e2d3ea23a0f8f84ef"}, {file = "lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba"},
] ]
[package.dependencies] [package.dependencies]
@@ -4592,4 +4592,4 @@ migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "2cd12b894b669ffac32a2584e899fcc6116c625b5b59d16586fe5ec1b1edc5f0" content-hash = "6b3b2f3c3163bc7a7bc2029ff6dd67f67c37057d9efbdf866b9d744f9e4ee9a5"
+3 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.3.0-rc5" version = "1.3.0-rc8"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -14,7 +14,7 @@ dependencies = [
"starlette==0.47.1", "starlette==0.47.1",
"httpx==0.27.0", "httpx==0.27.0",
"jinja2==3.1.6", "jinja2==3.1.6",
"lnurl==0.8.0", "lnurl==0.8.3",
"pydantic==1.10.22", "pydantic==1.10.22",
"pyqrcode==1.2.1", "pyqrcode==1.2.1",
"shortuuid==1.0.13", "shortuuid==1.0.13",
@@ -250,6 +250,7 @@ classmethod-decorators = [
# TODO: remove S101 ignore # TODO: remove S101 ignore
"lnbits/*" = ["S101"] "lnbits/*" = ["S101"]
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"] "lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
"lnbits/core/services/extensions_builder.py" = ["S701"]
"crypto.py" = ["S324"] "crypto.py" = ["S324"]
"test*.py" = ["S101", "S105", "S106", "S307"] "test*.py" = ["S101", "S105", "S106", "S307"]
"tools*.py" = ["S101", "S608"] "tools*.py" = ["S101", "S608"]
+9 -20
View File
@@ -1,5 +1,4 @@
import hashlib import hashlib
from http import HTTPStatus
from json import JSONDecodeError from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -591,31 +590,26 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"minWithdrawable": 1000, "minWithdrawable": 1000,
"maxWithdrawable": 1_500_000, "maxWithdrawable": 1_500_000,
}, },
{ {"status": "OK"},
"status": "OK", {"success": True, "message": "Payment sent with NFC."},
},
{
"status": "OK",
},
), ),
# Error loading LNURL request # Error loading LNURL request
( (
"error_loading_lnurl", "error_loading_lnurl",
None, None,
{ {
"status": "ERROR", "detail": "Error loading callback request",
"reason": "Error loading callback request",
}, },
), ),
# LNURL response with error status # LNURL response with error status
( (
{ {
"status": "ERROR", "status": "ERROR",
"reason": "Invalid LNURL-withdraw response.",
}, },
None, None,
{ {
"status": "ERROR", "detail": "Invalid LNURL-withdraw response.",
"reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Invalid LNURL-withdraw pay request # Invalid LNURL-withdraw pay request
@@ -629,8 +623,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
}, },
None, None,
{ {
"status": "ERROR", "detail": "Invalid LNURL-withdraw response.",
"reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Error loading callback request # Error loading callback request
@@ -644,8 +637,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
}, },
"error_loading_callback", "error_loading_callback",
{ {
"status": "ERROR", "detail": "Error loading callback request",
"reason": "Error loading callback request",
}, },
), ),
# Callback response with error status # Callback response with error status
@@ -662,8 +654,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"reason": "Callback failed", "reason": "Callback failed",
}, },
{ {
"status": "ERROR", "detail": "Callback failed",
"reason": "Callback failed",
}, },
), ),
# Unexpected exception during LNURL response JSON parsing # Unexpected exception during LNURL response JSON parsing
@@ -671,8 +662,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"exception_in_lnurl_response_json", "exception_in_lnurl_response_json",
None, None,
{ {
"status": "ERROR", "detail": "Invalid JSON response from https://example.com/lnurl",
"reason": "Invalid JSON response from https://example.com/lnurl",
}, },
), ),
], ],
@@ -768,7 +758,6 @@ async def test_api_payment_pay_with_nfc(
json={"lnurl_w": lnurl}, json={"lnurl_w": lnurl},
) )
assert response.status_code == HTTPStatus.OK
assert response.json() == expected_response assert response.json() == expected_response
@@ -2474,7 +2474,7 @@
}, },
"phoenixd": { "phoenixd": {
"get_payment_status_endpoint": { "get_payment_status_endpoint": {
"uri": "/payments/outgoing/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", "uri": "/payments/outgoingbyhash/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"headers": { "headers": {
"Authorization": "Basic OmYxNzFiYTAyMmE3NjRlNjc5ZWVmOTUwYjIxZmIxYzA0ZjE3MWJhMDIyYTc2NGU2NzllZWY5NTBiMjFmYjFjMDQ=", "Authorization": "Basic OmYxNzFiYTAyMmE3NjRlNjc5ZWVmOTUwYjIxZmIxYzA0ZjE3MWJhMDIyYTc2NGU2NzllZWY5NTBiMjFmYjFjMDQ=",
"User-Agent": "LNbits/Tests" "User-Agent": "LNbits/Tests"
Generated
+5 -5
View File
@@ -1260,7 +1260,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.3.0rc5" version = "1.3.0rc8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -1363,7 +1363,7 @@ requires-dist = [
{ name = "itsdangerous", specifier = "==2.2.0" }, { name = "itsdangerous", specifier = "==2.2.0" },
{ name = "jinja2", specifier = "==3.1.6" }, { name = "jinja2", specifier = "==3.1.6" },
{ name = "jsonpath-ng", specifier = "==1.7.0" }, { name = "jsonpath-ng", specifier = "==1.7.0" },
{ name = "lnurl", specifier = "==0.8.0" }, { name = "lnurl", specifier = "==0.8.3" },
{ name = "loguru", specifier = "==0.7.3" }, { name = "loguru", specifier = "==0.7.3" },
{ name = "nostr-sdk", specifier = "==0.42.1" }, { name = "nostr-sdk", specifier = "==0.42.1" },
{ name = "packaging", specifier = "==25.0" }, { name = "packaging", specifier = "==25.0" },
@@ -1418,7 +1418,7 @@ dev = [
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.8.0" version = "0.8.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "bech32" }, { name = "bech32" },
@@ -1429,9 +1429,9 @@ dependencies = [
{ name = "pycryptodomex" }, { name = "pycryptodomex" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/9e4f4ce76c50825b5ae4935affa877941f41ce8e44ef0f9c5a6757c1243f/lnurl-0.8.0.tar.gz", hash = "sha256:fa970f7d343caae7435848c592dd223d7ac8120e337d2d5e2d3ea23a0f8f84ef", size = 17008, upload-time = "2025-08-21T12:30:11.315Z" } sdist = { url = "https://files.pythonhosted.org/packages/9b/dc/29cce6bb8688622c7f5f6db4355c54b5c87bb05be58e49374fd2c6f4b0f2/lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba", size = 17171, upload-time = "2025-09-08T13:30:56.636Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/16/fdfb77478b95b7863d7822683a1fed34c0ef66bc857d8ebc52687b5f3833/lnurl-0.8.0-py3-none-any.whl", hash = "sha256:260be16dbcc45fd7dd937f781bd79c9fdaf38b4bb04d6e13a1820f24eac9389c", size = 17280, upload-time = "2025-08-21T12:30:10.32Z" }, { url = "https://files.pythonhosted.org/packages/fd/99/e400734afb7469a0cfa661259de2bb66417d6c3f14361444a010e9d186ee/lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a", size = 17459, upload-time = "2025-09-08T13:30:55.691Z" },
] ]
[[package]] [[package]]