Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8069c531d | ||
|
|
a9374d509c | ||
|
|
3e72788fc8 | ||
|
|
f48d24aca8 | ||
|
|
015262c9b3 | ||
|
|
e5e481f836 | ||
|
|
6c32ebf7e6 | ||
|
|
7c88702754 | ||
|
|
210b8a7c18 | ||
|
|
4f9a5090c2 | ||
|
|
c05122e5fb | ||
|
|
c054e47913 | ||
|
|
92d3269a85 | ||
|
|
183b84c167 | ||
|
|
60f50a71a2 |
+88
-59
@@ -1,11 +1,95 @@
|
|||||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### .env ONLY SETTINGS ############
|
||||||
|
######################################
|
||||||
|
# The following settings are ONLY set in your .env file.
|
||||||
|
# They are NOT managed by the Admin UI and are not stored in the database.
|
||||||
|
|
||||||
|
# === Logging and Development ===
|
||||||
|
|
||||||
|
DEBUG=False
|
||||||
|
DEBUG_DATABASE=False
|
||||||
|
BUNDLE_ASSETS=True
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|
||||||
|
# === Admin Settings ===
|
||||||
|
|
||||||
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
|
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||||
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5000
|
||||||
|
# VERSION=
|
||||||
|
# USER_AGENT=
|
||||||
|
|
||||||
|
# === LNbits ===
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# which fundingsources are allowed in the admin ui
|
||||||
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||||
|
|
||||||
|
# uvicorn variable, allow https behind a proxy
|
||||||
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
|
FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# ID of the super user. The user ID must exist.
|
||||||
|
# SUPER_USER=""
|
||||||
|
|
||||||
|
# LNBITS_TITLE="LNbits API"
|
||||||
|
# LNBITS_PATH="folder/path"
|
||||||
|
|
||||||
|
# === Auth Configurations ===
|
||||||
|
|
||||||
|
# Secret Key: will default to the hash of the super user.
|
||||||
|
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
||||||
|
AUTH_SECRET_KEY=""
|
||||||
|
|
||||||
|
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||||
|
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### END .env ONLY SETTINGS ########
|
||||||
|
######################################
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
####### Auth Configurations ##########
|
####### Auth Configurations ##########
|
||||||
######################################
|
######################################
|
||||||
# Secret Key: will default to the hash of the super user.
|
|
||||||
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
|
||||||
AUTH_SECRET_KEY=""
|
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
@@ -16,14 +100,6 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
|||||||
########### Admin Settings ###########
|
########### Admin Settings ###########
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
|
||||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
|
||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
|
||||||
# The rest of the settings will be stored in your database and you will be able to change them
|
|
||||||
# only through the Admin UI.
|
|
||||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
|
||||||
LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||||
@@ -32,22 +108,14 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f
|
|||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
HOST=127.0.0.1
|
|
||||||
PORT=5000
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
########## Funding Source ############
|
########## Funding Source ############
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
|
||||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
|
||||||
|
|
||||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||||
LIGHTNING_INVOICE_EXPIRY=3600
|
LIGHTNING_INVOICE_EXPIRY=3600
|
||||||
@@ -195,11 +263,6 @@ KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
|||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
|
||||||
FORWARDED_ALLOW_IPS="*"
|
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
@@ -210,8 +273,7 @@ LNBITS_BLOCKED_IPS=""
|
|||||||
# if set new users will not be able to create accounts
|
# if set new users will not be able to create accounts
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
# ID of the super user. The user ID must exist.
|
|
||||||
# SUPER_USER=""
|
|
||||||
|
|
||||||
# Extensions only admin can access
|
# Extensions only admin can access
|
||||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||||
@@ -244,26 +306,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
|||||||
# Hides wallet api, extensions can choose to honor
|
# Hides wallet api, extensions can choose to honor
|
||||||
LNBITS_HIDE_API=false
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
|
||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# Path where extensions will be installed (defaults to `./lnbits/`).
|
|
||||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
|
||||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
|
||||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
|
||||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
|
||||||
# psycopg2 as an additional dependency
|
|
||||||
LNBITS_DATA_FOLDER="./data"
|
|
||||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
|
||||||
|
|
||||||
# the service fee (in percent)
|
# the service fee (in percent)
|
||||||
LNBITS_SERVICE_FEE=0.0
|
LNBITS_SERVICE_FEE=0.0
|
||||||
# the wallet where fees go to
|
# the wallet where fees go to
|
||||||
@@ -292,16 +334,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
|||||||
###### Logging and Development #######
|
###### Logging and Development #######
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
DEBUG=false
|
|
||||||
DEBUG_DATABASE=false
|
|
||||||
BUNDLE_ASSETS=true
|
|
||||||
|
|
||||||
# logging into LNBITS_DATA_FOLDER/logs/
|
|
||||||
ENABLE_LOG_TO_FILE=true
|
|
||||||
|
|
||||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
|
||||||
LOG_ROTATION="100 MB"
|
|
||||||
LOG_RETENTION="3 months"
|
|
||||||
|
|
||||||
# for database cleanup commands
|
|
||||||
# CLEANUP_WALLETS_DAYS=90
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build LNbits AppImage DMG
|
name: Build LNbits AppImage
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -11,56 +11,89 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Step 1: Checkout the repository
|
# Step 1: Checkout the repository
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Step 2: Install Dependencies
|
# Step 2: Set up Python (uv will still use this toolchain)
|
||||||
- name: Install Dependencies
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Step 3: Install system deps (fuse) + uv
|
||||||
|
- name: Install system deps and uv
|
||||||
run: |
|
run: |
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libfuse2
|
sudo apt-get install -y libfuse2
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 3: Clone LNbits Repository
|
# Optional: Cache uv + venv to speed up CI
|
||||||
- name: Clone LNbits
|
- name: Cache uv and venv
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/uv
|
||||||
|
.venv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||||
|
|
||||||
|
# Step 4: Prepare packaging tree and clone LNbits
|
||||||
|
- name: Prepare packaging & clone LNbits
|
||||||
run: |
|
run: |
|
||||||
mv .github/packaging packaging
|
mv .github/packaging packaging
|
||||||
mkdir -p packaging/linux/AppDir/usr
|
mkdir -p packaging/linux/AppDir/usr
|
||||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 4: Make the AppImage Asset
|
# Step 5: Build the LNbits binary with uv + PyInstaller
|
||||||
- name: Make Asset
|
- name: Build LNbits binary (uv + PyInstaller)
|
||||||
run: |
|
run: |
|
||||||
cd packaging/linux/AppDir/usr/lnbits
|
cd packaging/linux/AppDir/usr/lnbits
|
||||||
poetry install
|
|
||||||
poetry run pip install pyinstaller
|
# Install project deps into .venv using uv
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install PyInstaller into the same environment
|
||||||
|
uv pip install pyinstaller
|
||||||
|
|
||||||
# Build the LNbits binary
|
# Build the LNbits binary
|
||||||
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
|
uv run pyinstaller \
|
||||||
|
--onefile \
|
||||||
|
--name lnbits \
|
||||||
|
--hidden-import=embit \
|
||||||
|
--collect-all embit \
|
||||||
|
--collect-all lnbits \
|
||||||
|
--collect-all sqlalchemy \
|
||||||
|
--collect-all aiosqlite \
|
||||||
|
--hidden-import=passlib.handlers.bcrypt \
|
||||||
|
"$(uv run which lnbits)"
|
||||||
|
|
||||||
cd ../../../../..
|
cd ../../../../..
|
||||||
chmod +x packaging/linux/AppDir/AppRun
|
chmod +x packaging/linux/AppDir/AppRun
|
||||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||||
|
|
||||||
|
# Clean out non-dist content from the app dir to keep AppImage slim
|
||||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||||
! -name 'dist' \
|
! -name 'dist' \
|
||||||
! -name 'lnbits' \
|
! -name 'lnbits' \
|
||||||
-exec rm -rf {} +
|
-exec rm -rf {} +
|
||||||
|
|
||||||
|
# Build AppImage
|
||||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x appimagetool-x86_64.AppImage
|
chmod +x appimagetool-x86_64.AppImage
|
||||||
TAG_NAME=${{ github.event.release.tag_name }}
|
TAG_NAME=${{ github.event.release.tag_name }}
|
||||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||||
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
./appimagetool-x86_64.AppImage \
|
||||||
|
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||||
|
packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||||
chmod +x "$APPIMAGE_NAME"
|
chmod +x "$APPIMAGE_NAME"
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 5: Upload Linux Release Asset
|
# Step 6: Upload Linux Release Asset
|
||||||
- name: Upload Linux Release Asset
|
- name: Upload Linux Release Asset
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -69,4 +102,4 @@ jobs:
|
|||||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||||
asset_content_type: application/octet-stream
|
asset_content_type: application/octet-stream
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = { };
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
|
|||||||
info_notification: str | None
|
info_notification: str | None
|
||||||
critical_notification: str | None
|
critical_notification: str | None
|
||||||
details_link: str | None
|
details_link: str | None
|
||||||
|
paid_features: str | None
|
||||||
pay_link: str | None
|
pay_link: str | None
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
details_link: str | None = None
|
details_link: str | None = None
|
||||||
|
|
||||||
|
paid_features: str | None = None
|
||||||
pay_link: str | None = None
|
pay_link: str | None = None
|
||||||
cost_sats: int | None = None
|
cost_sats: int | None = None
|
||||||
paid_sats: int | None = 0
|
paid_sats: int | None = 0
|
||||||
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
details_link=e.details_link,
|
details_link=e.details_link,
|
||||||
pay_link=e.pay_link,
|
pay_link=e.pay_link,
|
||||||
|
paid_features=e.paid_features,
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
|
|||||||
dependencies: list[str] = []
|
dependencies: list[str] = []
|
||||||
archive: str | None = None
|
archive: str | None = None
|
||||||
featured: bool = False
|
featured: bool = False
|
||||||
|
paid_features: str | None = None
|
||||||
|
has_paid_release: bool = False
|
||||||
|
has_free_release: bool = False
|
||||||
|
|
||||||
|
|
||||||
class InstallableExtension(BaseModel):
|
class InstallableExtension(BaseModel):
|
||||||
@@ -409,7 +415,6 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||||
shutil.rmtree(tmp_dir, True)
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||||
zip_ref.extractall(tmp_dir)
|
zip_ref.extractall(tmp_dir)
|
||||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||||
@@ -452,9 +457,23 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||||
|
|
||||||
def check_latest_version(self, release: ExtensionRelease | None):
|
def check_release_updates(self, release: ExtensionRelease | None):
|
||||||
|
self._check_latest_version(release)
|
||||||
|
self._check_payment_link(release)
|
||||||
|
|
||||||
|
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||||
|
if not pay_link or not self.meta or not self.meta.payments:
|
||||||
|
return None
|
||||||
|
return next(
|
||||||
|
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_latest_version(self, release: ExtensionRelease | None):
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
|
if not release.is_version_compatible:
|
||||||
|
return
|
||||||
if not self.meta or not self.meta.latest_release:
|
if not self.meta or not self.meta.latest_release:
|
||||||
meta = self.meta or ExtensionMeta()
|
meta = self.meta or ExtensionMeta()
|
||||||
meta.latest_release = release
|
meta.latest_release = release
|
||||||
@@ -465,13 +484,19 @@ class InstallableExtension(BaseModel):
|
|||||||
):
|
):
|
||||||
self.meta.latest_release = release
|
self.meta.latest_release = release
|
||||||
|
|
||||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
def _check_payment_link(self, release: ExtensionRelease | None):
|
||||||
if not pay_link or not self.meta or not self.meta.payments:
|
if not release:
|
||||||
return None
|
return
|
||||||
return next(
|
if not release.is_version_compatible:
|
||||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
return
|
||||||
None,
|
if not self.meta:
|
||||||
)
|
self.meta = ExtensionMeta()
|
||||||
|
if release.pay_link:
|
||||||
|
self.meta.has_paid_release = True
|
||||||
|
else:
|
||||||
|
self.meta.has_free_release = True
|
||||||
|
if release.paid_features:
|
||||||
|
self.meta.paid_features = release.paid_features
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
def _restore_payment_info(self):
|
||||||
if (
|
if (
|
||||||
@@ -597,7 +622,7 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == r.id), None
|
(ee for ee in extension_list if ee.id == r.id), None
|
||||||
)
|
)
|
||||||
if existing_ext and ext.meta:
|
if existing_ext and ext.meta:
|
||||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
existing_ext.check_release_updates(ext.meta.latest_release)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
@@ -611,10 +636,10 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == e.id), None
|
(ee for ee in extension_list if ee.id == e.id), None
|
||||||
)
|
)
|
||||||
if existing_ext:
|
if existing_ext:
|
||||||
existing_ext.check_latest_version(release)
|
existing_ext.check_release_updates(release)
|
||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_latest_version(release)
|
ext.check_release_updates(release)
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
meta.featured = ext.id in manifest.featured
|
meta.featured = ext.id in manifest.featured
|
||||||
ext.meta = meta
|
ext.meta = meta
|
||||||
@@ -628,8 +653,11 @@ class InstallableExtension(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||||
extension_releases: list[ExtensionRelease] = []
|
extension_releases: list[ExtensionRelease] = []
|
||||||
|
all_manifests = [
|
||||||
for url in settings.lnbits_extensions_manifests:
|
*settings.lnbits_extensions_manifests,
|
||||||
|
settings.lnbits_extensions_builder_manifest_url,
|
||||||
|
]
|
||||||
|
for url in all_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, validator
|
||||||
|
|
||||||
|
from lnbits.helpers import (
|
||||||
|
camel_to_snake,
|
||||||
|
is_camel_case,
|
||||||
|
is_snake_case,
|
||||||
|
urlsafe_short_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataField(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
label: str | None = None
|
||||||
|
hint: str | None = None
|
||||||
|
optional: bool = False
|
||||||
|
editable: bool = False
|
||||||
|
searchable: bool = False
|
||||||
|
sortable: bool = False
|
||||||
|
fields: list[DataField] = []
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.name = self.name.strip()
|
||||||
|
self.type = self.type.strip()
|
||||||
|
if self.label:
|
||||||
|
self.label = self.label.strip()
|
||||||
|
if self.hint:
|
||||||
|
self.hint = self.hint.strip()
|
||||||
|
if self.type == "json":
|
||||||
|
self.editable = False
|
||||||
|
self.searchable = False
|
||||||
|
self.sortable = False
|
||||||
|
else:
|
||||||
|
self.fields = []
|
||||||
|
|
||||||
|
for field in self.fields:
|
||||||
|
field.normalize()
|
||||||
|
|
||||||
|
def field_to_py(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
field_type = self.type
|
||||||
|
if self.type == "json":
|
||||||
|
field_type = "dict"
|
||||||
|
elif self.type in ["wallet", "currency", "text"]:
|
||||||
|
field_type = "str"
|
||||||
|
if self.optional:
|
||||||
|
field_type += " | None"
|
||||||
|
if self.type == "currency":
|
||||||
|
field_type += ' = "sat"'
|
||||||
|
return f"{field_name}: {field_type}"
|
||||||
|
|
||||||
|
def field_to_js(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
default_value = "null"
|
||||||
|
if self.type == "json":
|
||||||
|
default_value = "{}"
|
||||||
|
if self.type == "currency":
|
||||||
|
default_value = '"sat"'
|
||||||
|
return f"{field_name}: {default_value}"
|
||||||
|
|
||||||
|
def field_to_ui_table_column(self) -> str:
|
||||||
|
column = {
|
||||||
|
"name": self.name,
|
||||||
|
"align": "left",
|
||||||
|
"label": self.label or self.name,
|
||||||
|
"field": self.name,
|
||||||
|
"sortable": self.sortable,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(column)
|
||||||
|
|
||||||
|
def field_to_db(self) -> str:
|
||||||
|
field_name = camel_to_snake(self.name)
|
||||||
|
field_type = self.type
|
||||||
|
if field_type == "str":
|
||||||
|
db_type = "TEXT"
|
||||||
|
elif field_type == "int":
|
||||||
|
db_type = "INT"
|
||||||
|
elif field_type == "float":
|
||||||
|
db_type = "REAL"
|
||||||
|
elif field_type == "bool":
|
||||||
|
db_type = "BOOLEAN"
|
||||||
|
elif field_type == "datetime":
|
||||||
|
db_type = "TIMESTAMP"
|
||||||
|
else:
|
||||||
|
db_type = "TEXT"
|
||||||
|
|
||||||
|
db_field = f"{field_name} {db_type}"
|
||||||
|
if not self.optional:
|
||||||
|
db_field += " NOT NULL"
|
||||||
|
if field_type == "json":
|
||||||
|
db_field += " DEFAULT '{empty_dict}'"
|
||||||
|
return db_field
|
||||||
|
|
||||||
|
def field_mock_value(self, index: int) -> Any:
|
||||||
|
if self.name == "id":
|
||||||
|
return urlsafe_short_hash()
|
||||||
|
if self.type == "int":
|
||||||
|
return index
|
||||||
|
elif self.type == "float":
|
||||||
|
return float(f"{index}.0{index * 2}")
|
||||||
|
elif self.type == "bool":
|
||||||
|
return True if index % 2 == 0 else False
|
||||||
|
elif self.type == "datetime":
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
|
||||||
|
elif self.type == "json":
|
||||||
|
return {"key": "value"}
|
||||||
|
elif self.type == "currency":
|
||||||
|
return "USD"
|
||||||
|
else:
|
||||||
|
return f"{self.name} {index}"
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Field name is required.")
|
||||||
|
if not is_snake_case(v):
|
||||||
|
raise ValueError(f"Field Name must be snake_case. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("type")
|
||||||
|
def validate_type(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Owner Data type is required")
|
||||||
|
if v not in [
|
||||||
|
"str",
|
||||||
|
"int",
|
||||||
|
"float",
|
||||||
|
"bool",
|
||||||
|
"datetime",
|
||||||
|
"json",
|
||||||
|
"wallet",
|
||||||
|
"currency",
|
||||||
|
"text",
|
||||||
|
]:
|
||||||
|
raise ValueError(
|
||||||
|
"Field Type must be one of: "
|
||||||
|
"str, int, float, bool, datetime, json, wallet, currency, text."
|
||||||
|
f" Found: {v}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("label")
|
||||||
|
def validate_label(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field label cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("hint")
|
||||||
|
def validate_hint(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DataFields(BaseModel):
|
||||||
|
name: str
|
||||||
|
editable: bool = True
|
||||||
|
fields: list[DataField] = []
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
self.normalize()
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.name = self.name.strip()
|
||||||
|
for field in self.fields:
|
||||||
|
field.normalize()
|
||||||
|
if all(not field.editable for field in self.fields):
|
||||||
|
self.editable = False
|
||||||
|
|
||||||
|
def get_field_by_name(self, name: str | None) -> DataField | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
for field in self.fields:
|
||||||
|
if field.name == name:
|
||||||
|
return field
|
||||||
|
return None
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Data fields name is required")
|
||||||
|
if not is_camel_case(v):
|
||||||
|
raise ValueError(f"Data name must be CamelCase. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsFields(DataFields):
|
||||||
|
enabled: bool = False
|
||||||
|
type: str = "user"
|
||||||
|
|
||||||
|
@validator("type")
|
||||||
|
def validate_type(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Settings type is required")
|
||||||
|
if v not in ["user", "admin"]:
|
||||||
|
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ActionFields(BaseModel):
|
||||||
|
generate_action: bool = False
|
||||||
|
generate_payment_logic: bool = False
|
||||||
|
wallet_id: str | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
amount: str | None = None
|
||||||
|
amount_source: Literal["owner_data", "client_data"] | None = None
|
||||||
|
paid_flag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerDataFields(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClientDataFields(BaseModel):
|
||||||
|
public_inputs: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPageFields(BaseModel):
|
||||||
|
has_public_page: bool = False
|
||||||
|
owner_data_fields: OwnerDataFields
|
||||||
|
client_data_fields: ClientDataFields
|
||||||
|
action_fields: ActionFields
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewAction(BaseModel):
|
||||||
|
is_preview_mode: bool = False
|
||||||
|
is_settings_preview: bool = False
|
||||||
|
is_owner_data_preview: bool = False
|
||||||
|
is_client_data_preview: bool = False
|
||||||
|
is_public_page_preview: bool = False
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
if not self.is_preview_mode:
|
||||||
|
self.is_settings_preview = False
|
||||||
|
self.is_owner_data_preview = False
|
||||||
|
self.is_client_data_preview = False
|
||||||
|
self.is_public_page_preview = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionData(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
stub_version: str | None
|
||||||
|
short_description: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
owner_data: DataFields
|
||||||
|
client_data: DataFields
|
||||||
|
settings_data: SettingsFields
|
||||||
|
public_page: PublicPageFields
|
||||||
|
preview_action: PreviewAction = PreviewAction()
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
self.validate_data()
|
||||||
|
self.normalize()
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
self.id = self.id.strip()
|
||||||
|
self.name = self.name.strip()
|
||||||
|
if self.stub_version:
|
||||||
|
self.stub_version = self.stub_version.strip()
|
||||||
|
if self.short_description:
|
||||||
|
self.short_description = self.short_description.strip()
|
||||||
|
if self.description:
|
||||||
|
self.description = self.description.strip()
|
||||||
|
if not self.public_page.has_public_page:
|
||||||
|
self.public_page.action_fields.generate_action = False
|
||||||
|
self.public_page.action_fields.generate_payment_logic = False
|
||||||
|
if not self.public_page.action_fields.generate_action:
|
||||||
|
self.public_page.action_fields.generate_payment_logic = False
|
||||||
|
|
||||||
|
def validate_data(self) -> None:
|
||||||
|
self._validate_field_names()
|
||||||
|
self._validate_public_page_fields()
|
||||||
|
self._validate_action_fields()
|
||||||
|
|
||||||
|
def _validate_public_page_fields(self) -> None:
|
||||||
|
if not self.public_page.has_public_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
public_page_name = self.public_page.owner_data_fields.name
|
||||||
|
if public_page_name:
|
||||||
|
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
|
||||||
|
if not public_page_name_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Name must be one of the owner data fields."
|
||||||
|
f" Received: {public_page_name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
public_page_description = self.public_page.owner_data_fields.description
|
||||||
|
if public_page_description:
|
||||||
|
public_page_description_field = self.owner_data.get_field_by_name(
|
||||||
|
public_page_description
|
||||||
|
)
|
||||||
|
if not public_page_description_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Description must be one of the owner data fields."
|
||||||
|
f" Received: {public_page_description}."
|
||||||
|
)
|
||||||
|
|
||||||
|
public_page_inputs = self.public_page.client_data_fields.public_inputs
|
||||||
|
if public_page_inputs:
|
||||||
|
for input_field in public_page_inputs:
|
||||||
|
input_field_obj = self.client_data.get_field_by_name(input_field)
|
||||||
|
if not input_field_obj:
|
||||||
|
raise ValueError(
|
||||||
|
"Public Page Input fields"
|
||||||
|
" must be one of the client data fields."
|
||||||
|
f" Received: {input_field}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_action_fields(self) -> None:
|
||||||
|
if not self.public_page.action_fields.generate_action:
|
||||||
|
return
|
||||||
|
if not self.public_page.action_fields.generate_payment_logic:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._validate_owner_data_fields()
|
||||||
|
self._validate_client_data_fields()
|
||||||
|
|
||||||
|
def _validate_owner_data_fields(self) -> None:
|
||||||
|
wallet_id = self.public_page.action_fields.wallet_id
|
||||||
|
if wallet_id:
|
||||||
|
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
|
||||||
|
if not wallet_id_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Wallet ID must be one of the owner data fields."
|
||||||
|
f" Received: {wallet_id}."
|
||||||
|
)
|
||||||
|
if wallet_id_field.type != "wallet":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Wallet ID field type must be 'wallet'."
|
||||||
|
f" Received: {wallet_id_field.type}."
|
||||||
|
)
|
||||||
|
currency = self.public_page.action_fields.currency
|
||||||
|
if currency:
|
||||||
|
currency_field = self.owner_data.get_field_by_name(currency)
|
||||||
|
if not currency_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Currency must be one of the owner data fields."
|
||||||
|
f" Received: {currency}."
|
||||||
|
)
|
||||||
|
if currency_field.type != "currency":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Currency field type must be 'currency'."
|
||||||
|
f" Received: {currency_field.type}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_field_names(self) -> None:
|
||||||
|
reserved_names = {"id", "created_at", "updated_at"}
|
||||||
|
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
|
||||||
|
if nok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_client_data_fields(self) -> None:
|
||||||
|
amount = self.public_page.action_fields.amount
|
||||||
|
amount_source = self.public_page.action_fields.amount_source
|
||||||
|
if amount_source and amount:
|
||||||
|
if amount_source == "owner_data":
|
||||||
|
amount_field = self.owner_data.get_field_by_name(amount)
|
||||||
|
else:
|
||||||
|
amount_field = self.client_data.get_field_by_name(amount)
|
||||||
|
if not amount_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Amount must be one of the "
|
||||||
|
"client data or owner data fields."
|
||||||
|
f" Received: {amount}."
|
||||||
|
)
|
||||||
|
if amount_field.type not in ["int", "float"]:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Amount field type must be 'int' or 'float'."
|
||||||
|
f" Received: {amount_field.type}."
|
||||||
|
)
|
||||||
|
paid_flag = self.public_page.action_fields.paid_flag
|
||||||
|
if paid_flag:
|
||||||
|
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
|
||||||
|
if not paid_flag_field:
|
||||||
|
raise ValueError(
|
||||||
|
"Action Paid Flag must be one of the client data fields."
|
||||||
|
f" Received: {paid_flag}."
|
||||||
|
)
|
||||||
|
if paid_flag_field.type != "bool":
|
||||||
|
raise ValueError(
|
||||||
|
"Action Paid Flag field type must be 'bool'."
|
||||||
|
f" Received: {paid_flag_field.type}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@validator("id")
|
||||||
|
def validate_id(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Extension ID is required")
|
||||||
|
if not is_snake_case(v):
|
||||||
|
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("name")
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
if v.strip() == "":
|
||||||
|
raise ValueError("Extension name is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("stub_version")
|
||||||
|
def validate_stub_version(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Extension stub version cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("short_description")
|
||||||
|
def validate_short_description(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field short description cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("description")
|
||||||
|
def validate_description(cls, v: str | None) -> str | None:
|
||||||
|
if v and '"' in v:
|
||||||
|
raise ValueError(
|
||||||
|
f'Field description cannot contain double quotes ("). Value: {v}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("owner_data")
|
||||||
|
def validate_owner_data(cls, v: DataFields) -> DataFields:
|
||||||
|
if len(v.fields) == 0:
|
||||||
|
raise ValueError("At least one owner data field is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator("client_data")
|
||||||
|
def validate_client_data(cls, v: DataFields) -> DataFields:
|
||||||
|
if len(v.fields) == 0:
|
||||||
|
raise ValueError("At least one client data field is required")
|
||||||
|
return v
|
||||||
@@ -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,7 +37,8 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
|||||||
if installed_ext and installed_ext.meta:
|
if installed_ext and installed_ext.meta:
|
||||||
ext_info.meta.payments = installed_ext.meta.payments
|
ext_info.meta.payments = installed_ext.meta.payments
|
||||||
|
|
||||||
await ext_info.download_archive()
|
if not skip_download:
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -903,7 +948,6 @@
|
|||||||
:href="selectedExtensionDetails.repo"
|
:href="selectedExtensionDetails.repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="q-pr-xs"
|
|
||||||
><q-tooltip>repository</q-tooltip></q-btn
|
><q-tooltip>repository</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||||
|
<!---->
|
||||||
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
|
<!---->
|
||||||
|
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-stepper
|
||||||
|
v-model="step"
|
||||||
|
ref="stepper"
|
||||||
|
color="primary"
|
||||||
|
animated
|
||||||
|
header-nav
|
||||||
|
class="q-pt-sm"
|
||||||
|
@update:model-value="onStepChange"
|
||||||
|
>
|
||||||
|
<q-step
|
||||||
|
:name="1"
|
||||||
|
title="Describe"
|
||||||
|
icon="info"
|
||||||
|
:done="step > 1"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<span class="text-h6">
|
||||||
|
Tell us something about your extension:
|
||||||
|
</span>
|
||||||
|
<ul>
|
||||||
|
<li>This is the first step, you can return and change it.</li>
|
||||||
|
<li>
|
||||||
|
The <code>`name`</code> and
|
||||||
|
<code>`sort description`</code> fields are what the users will
|
||||||
|
see when browsing the list of extensions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The <code>`id`</code> field is used internally and in the URL of
|
||||||
|
your extension.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- todo: add icon -->
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="Upload Existing config"
|
||||||
|
@click="$refs.extensionDataInput.click()"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="extensionDataInput"
|
||||||
|
accept="application/json"
|
||||||
|
style="display: none"
|
||||||
|
@change="onJsonDataInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-mt-sm"></q-separator>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.name"
|
||||||
|
label="Extension Name"
|
||||||
|
hint="The name of your extension"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.id"
|
||||||
|
label="Extension Id"
|
||||||
|
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.short_description"
|
||||||
|
label="Short Description"
|
||||||
|
hint="A short description that is shown in the extension list."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="extensionData.description"
|
||||||
|
label="Description"
|
||||||
|
hint="A detailed description of your extension."
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="2"
|
||||||
|
title="Settings"
|
||||||
|
icon="settings"
|
||||||
|
:done="step > 2"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep2"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('settings')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.settings_data.enabled"
|
||||||
|
label="Generate Settings Fields"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Define what settings your extension will have.</li>
|
||||||
|
<li>
|
||||||
|
You can choose if each user has its own settings or if the
|
||||||
|
settings are global (set by the admin).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.settings_data.enabled"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-md-2 col-sm-12">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.settings_data.type"
|
||||||
|
:options="settingsTypes"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10 col-sm-12 q-pt-sm">
|
||||||
|
<q-badge
|
||||||
|
v-if="extensionData.settings_data.type === 'user'"
|
||||||
|
outline
|
||||||
|
class="text-caption q-ml-md"
|
||||||
|
>Each user can set its own settings for this extension.</q-badge
|
||||||
|
>
|
||||||
|
<q-badge v-else outline class="text-caption q-ml-md"
|
||||||
|
>Settings are set by the admin and apply to all users of the
|
||||||
|
extension</q-badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.settings_data.fields"
|
||||||
|
:hide-advanced="true"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="3"
|
||||||
|
:done="step > 3"
|
||||||
|
title="Owner Data"
|
||||||
|
icon="list"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep3"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('owner_data')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-input
|
||||||
|
v-model="extensionData.owner_data.name"
|
||||||
|
filled
|
||||||
|
label="Owner Table Name"
|
||||||
|
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
|
||||||
|
class="q-mb-xl"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The owner of the extension manages this data. It can add, remove
|
||||||
|
and update instances of it.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Some fileds are present by default, like
|
||||||
|
<code>created_at</code>, <code>updated_at</code> and
|
||||||
|
<code>extra</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.owner_data.fields"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="4"
|
||||||
|
:done="step > 4"
|
||||||
|
title="Client Data"
|
||||||
|
icon="blur_linear"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep4"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('client_data')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<!-- <q-toggle
|
||||||
|
v-model="extensionData.client_data.enabled"
|
||||||
|
label="Generate Client Table"
|
||||||
|
disable
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br /> -->
|
||||||
|
<q-input
|
||||||
|
v-if="extensionData.client_data.enabled"
|
||||||
|
v-model="extensionData.client_data.name"
|
||||||
|
filled
|
||||||
|
label="Client Table Name"
|
||||||
|
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
|
||||||
|
class="q-mb-xl"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
This data is created by users of the extension. Usually when
|
||||||
|
they submit a form or make a payment.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The owner of the extension can view this data, but should not
|
||||||
|
modify it.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.client_data.enabled"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
|
||||||
|
<div class="col-12">
|
||||||
|
<lnbits-data-fields
|
||||||
|
:fields="extensionData.client_data.fields"
|
||||||
|
></lnbits-data-fields>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="5"
|
||||||
|
:done="step > 5"
|
||||||
|
title="Public Pages"
|
||||||
|
icon="link"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<iframe
|
||||||
|
ref="iframeStep5"
|
||||||
|
class="full-width"
|
||||||
|
height="400px"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-btn
|
||||||
|
@click="previewExtension('public_page')"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
label="Refresh Preview"
|
||||||
|
class="full-width q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.has_public_page"
|
||||||
|
label="Generate Public Page"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Most extensions have a public page that can be shared (this page
|
||||||
|
will still be accessible even if you have restricted access to
|
||||||
|
your LNbits install).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="extensionData.public_page.has_public_page">
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page title</q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
>Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code>
|
||||||
|
(Owner Data) that will be used as a title for the public
|
||||||
|
page.</q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.owner_data_fields.name"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page description</q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
>Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code>
|
||||||
|
(Owner Data) that will be used as a description for the
|
||||||
|
public page.</q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.owner_data_fields.description"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Public page inputs</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the fields from the
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that will be shown as inputs in
|
||||||
|
the public page form.
|
||||||
|
</li>
|
||||||
|
<li>You can select multiple fields.</li>
|
||||||
|
<li>
|
||||||
|
A corresponding input field will be created for each
|
||||||
|
selected field.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
v-model="extensionData.public_page.client_data_fields.public_inputs"
|
||||||
|
:options="extensionData.client_data.fields.map(f => f.name)"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Generate Action Button</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
If enabled, the public page will have a button to
|
||||||
|
perform an action (e.g. generate a payment request).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The action will use the selected input fields from
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) as parameters.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A corresponding REST API endpoint will be created.
|
||||||
|
</li>
|
||||||
|
</ul></q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.action_fields.generate_action"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator
|
||||||
|
v-if="extensionData.public_page.action_fields.generate_action"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-separator>
|
||||||
|
|
||||||
|
<div v-if="extensionData.public_page.action_fields.generate_action">
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Generate Payment Logic</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
If enabled, the endpoint will create an invoice from
|
||||||
|
the submitted data and the UI will show the QR code
|
||||||
|
with the invoice.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A listener will be created to check for the pay event.
|
||||||
|
</li>
|
||||||
|
<li>You must map the fieds.</li>
|
||||||
|
</ul></q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
v-model="extensionData.public_page.action_fields.generate_payment_logic"
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
|
||||||
|
class="row q-col-gutter-md q-mt-md"
|
||||||
|
>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Wallet</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) that represents the wallet which
|
||||||
|
will generate the invoice and receive the payments.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Wallet</code> will be
|
||||||
|
shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.wallet_id"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Currency</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) that represents the currency
|
||||||
|
which will be used to for the amount.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Currency</code> will
|
||||||
|
be shown.
|
||||||
|
</li>
|
||||||
|
<li>Empty if you want to use sats.</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.currency"
|
||||||
|
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Amount</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.owner_data.name"></code
|
||||||
|
> (Owner Data) or
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that represents the amount (in
|
||||||
|
the selected currency).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Integer</code> and
|
||||||
|
<code>Float</code> will be shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.amount_source"
|
||||||
|
:options="amountSource"
|
||||||
|
class="q-mr-sm"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.amount"
|
||||||
|
:options="paymentActionAmountFields"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Paid Flag</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<ul class="q-pa-none q-ma-none">
|
||||||
|
<li>
|
||||||
|
Select the field from the
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (Client Data) that will be set to true when
|
||||||
|
the invoice is paid.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only fields with the type <code>Boolean</code> will be
|
||||||
|
shown.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.public_page.action_fields.paid_flag"
|
||||||
|
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
|
||||||
|
></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="6"
|
||||||
|
:done="step > 6"
|
||||||
|
title="Publish"
|
||||||
|
icon="publish"
|
||||||
|
style="min-height: 100px"
|
||||||
|
>
|
||||||
|
<div v-if="g.user.admin" class="row">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="extensionData.stub_version"
|
||||||
|
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
|
||||||
|
:options="extensionStubVersions.map(f => f.version)"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<q-btn
|
||||||
|
@click="cleanCacheData()"
|
||||||
|
color="grey"
|
||||||
|
outline
|
||||||
|
label="Clean Cache"
|
||||||
|
class="q-ml-md"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
The extension builder uses caching to speed up the build
|
||||||
|
process.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This action clears old data and redownloads the Extension
|
||||||
|
Builder Stub release.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="g.user.admin" class="row q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<q-btn
|
||||||
|
@click="buildExtensionAndDeploy()"
|
||||||
|
color="primary"
|
||||||
|
label="Build and Deploy (Admin Only)"
|
||||||
|
class="col"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
Installs the extension directly to this LNbits instance.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The extension will be enabled by default, and available to
|
||||||
|
all users.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<q-btn
|
||||||
|
@click="buildExtension()"
|
||||||
|
outline
|
||||||
|
color="gray"
|
||||||
|
label="Download Extension Zip"
|
||||||
|
icon="download"
|
||||||
|
class="col"
|
||||||
|
/>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
Builds the extension and downloads a zip file with the code.
|
||||||
|
You can then install it manually in your LNbits instance.
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<template v-slot:navigation>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
|
||||||
|
<q-btn
|
||||||
|
v-if="step == 1"
|
||||||
|
label="Clear All Data"
|
||||||
|
color="negative"
|
||||||
|
@click="clearAllData"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
flat
|
||||||
|
color="grey-8"
|
||||||
|
class="q-mr-sm"
|
||||||
|
@click="previousStep()"
|
||||||
|
label="Back"
|
||||||
|
icon="chevron_left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
|
||||||
|
<q-stepper-navigation class="float-right">
|
||||||
|
<q-btn
|
||||||
|
v-if="step < 6"
|
||||||
|
@click="nextStep()"
|
||||||
|
color="primary"
|
||||||
|
label="Next"
|
||||||
|
></q-btn>
|
||||||
|
<template v-else>
|
||||||
|
<q-btn
|
||||||
|
@click="exportJsonData()"
|
||||||
|
color="primary"
|
||||||
|
label="Export JSON Data"
|
||||||
|
></q-btn>
|
||||||
|
<q-icon
|
||||||
|
name="info"
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer q-ml-sm self-center"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<ul class="q-pl-sm">
|
||||||
|
<li>
|
||||||
|
Exports the config JSON so it can be later imported or
|
||||||
|
shared.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This JSON is also added to the zip in a file called
|
||||||
|
`builder.json`.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
</q-stepper-navigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-stepper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md"></div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from hashlib import sha256
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import (
|
||||||
|
SimpleStatus,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from lnbits.core.models.extensions import (
|
||||||
|
Extension,
|
||||||
|
ExtensionMeta,
|
||||||
|
InstallableExtension,
|
||||||
|
UserExtension,
|
||||||
|
)
|
||||||
|
from lnbits.core.models.extensions_builder import ExtensionData
|
||||||
|
from lnbits.core.services.extensions import (
|
||||||
|
activate_extension,
|
||||||
|
install_extension,
|
||||||
|
)
|
||||||
|
from lnbits.core.services.extensions_builder import (
|
||||||
|
build_extension_from_data,
|
||||||
|
clean_extension_builder_data,
|
||||||
|
zip_directory,
|
||||||
|
)
|
||||||
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
|
check_user_exists,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
create_user_extension,
|
||||||
|
get_user_extension,
|
||||||
|
update_user_extension,
|
||||||
|
)
|
||||||
|
|
||||||
|
extension_builder_router = APIRouter(
|
||||||
|
tags=["Extension Managment"],
|
||||||
|
prefix="/api/v1/extension/builder",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/zip",
|
||||||
|
summary="Build and download extension zip.",
|
||||||
|
description="""
|
||||||
|
This endpoint generates a zip file for the extension based on the provided data.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def api_build_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> FileResponse:
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
|
||||||
|
release, build_dir = await build_extension_from_data(data, stub_ext_id)
|
||||||
|
|
||||||
|
ext_info = InstallableExtension(
|
||||||
|
id=data.id,
|
||||||
|
name=data.name,
|
||||||
|
version="0.1.0",
|
||||||
|
short_description=data.short_description,
|
||||||
|
meta=ExtensionMeta(installed_release=release),
|
||||||
|
)
|
||||||
|
ext_zip_file = ext_info.zip_path
|
||||||
|
if ext_zip_file.is_file():
|
||||||
|
os.remove(ext_zip_file)
|
||||||
|
|
||||||
|
zip_directory(build_dir, ext_zip_file)
|
||||||
|
shutil.rmtree(build_dir, True)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
ext_zip_file, filename=f"{data.id}.zip", media_type="application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/deploy",
|
||||||
|
summary="Build extension based on provided config.",
|
||||||
|
description="""
|
||||||
|
This endpoint generates a zip file for the extension based on the provided data.
|
||||||
|
If `deploy` is set to true, the extension will be installed and activated.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def api_deploy_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
) -> SimpleStatus:
|
||||||
|
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||||
|
stub_ext_id = "extension_builder_stub"
|
||||||
|
release, build_dir = await build_extension_from_data(
|
||||||
|
data, stub_ext_id, working_dir_name
|
||||||
|
)
|
||||||
|
|
||||||
|
ext_info = InstallableExtension(
|
||||||
|
id=data.id,
|
||||||
|
name=data.name,
|
||||||
|
version="0.1.0",
|
||||||
|
short_description=data.short_description,
|
||||||
|
meta=ExtensionMeta(installed_release=release),
|
||||||
|
icon=release.icon,
|
||||||
|
)
|
||||||
|
ext_zip_file = ext_info.zip_path
|
||||||
|
if ext_zip_file.is_file():
|
||||||
|
os.remove(ext_zip_file)
|
||||||
|
|
||||||
|
zip_directory(build_dir.parent, ext_zip_file)
|
||||||
|
|
||||||
|
await install_extension(ext_info, skip_download=True)
|
||||||
|
|
||||||
|
await activate_extension(Extension.from_installable_ext(ext_info))
|
||||||
|
|
||||||
|
user_ext = await get_user_extension(user.id, data.id)
|
||||||
|
if not user_ext:
|
||||||
|
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
|
||||||
|
await create_user_extension(user_ext)
|
||||||
|
elif not user_ext.active:
|
||||||
|
user_ext.active = True
|
||||||
|
await update_user_extension(user_ext)
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message=f"Extension '{data.id}' deployed.")
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.post(
|
||||||
|
"/preview",
|
||||||
|
summary="Build and preview the extension ui.",
|
||||||
|
)
|
||||||
|
async def api_preview_extension(
|
||||||
|
data: ExtensionData,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> SimpleStatus:
|
||||||
|
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"Extension Builder is disabled for non admin users.",
|
||||||
|
)
|
||||||
|
stub_ext_id = "extension_builder_stub"
|
||||||
|
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||||
|
await build_extension_from_data(data, stub_ext_id, working_dir_name)
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
|
||||||
|
|
||||||
|
|
||||||
|
@extension_builder_router.delete(
|
||||||
|
"",
|
||||||
|
summary="Clean extension builder data.",
|
||||||
|
description="""
|
||||||
|
This endpoint cleans the extension builder data.
|
||||||
|
""",
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def api_delete_extension_builder_data() -> SimpleStatus:
|
||||||
|
|
||||||
|
clean_extension_builder_data()
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message="Extension Builder data cleaned.")
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.core.services.fiat_providers import test_connection
|
from lnbits.core.services.fiat_providers import test_connection
|
||||||
from lnbits.decorators import check_admin
|
from lnbits.decorators import check_admin
|
||||||
|
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||||
|
|
||||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||||
|
|
||||||
@@ -16,3 +17,29 @@ fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
|||||||
)
|
)
|
||||||
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||||
return await test_connection(provider)
|
return await test_connection(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@fiat_router.post(
|
||||||
|
"/{provider}/connection_token",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def connection_token(provider: str):
|
||||||
|
provider_wallet = await get_fiat_provider(provider)
|
||||||
|
if provider == "stripe":
|
||||||
|
if not isinstance(provider_wallet, StripeWallet):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Stripe wallet/provider not configured"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tok = await provider_wallet.create_terminal_connection_token()
|
||||||
|
secret = tok.get("secret")
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail="Stripe returned no connection token"
|
||||||
|
)
|
||||||
|
return {"secret": secret}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to create connection token"
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -1,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,
|
||||||
|
|||||||
@@ -296,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:
|
||||||
return {"paid": False, "status": "failed", "details": payment}
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
|
return {"paid": False, "status": "failed", "details": payment}
|
||||||
|
return {"paid": False, "status": "failed"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import AsyncGenerator, Coroutine
|
from collections.abc import AsyncGenerator, Coroutine
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
@@ -106,6 +106,7 @@ class FiatProvider(ABC):
|
|||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
currency: str,
|
currency: str,
|
||||||
memo: str | None = None,
|
memo: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+223
-62
@@ -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,81 +108,47 @@ 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
|
if opts.fiat_method == "terminal":
|
||||||
|
return await self._create_terminal_invoice(
|
||||||
|
amount_cents, currency, payment_hash, opts
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
session_id = data.get("id")
|
return FiatInvoiceResponse(
|
||||||
if not session_id:
|
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
|
||||||
return FiatInvoiceResponse(
|
)
|
||||||
ok=False, error_message="Server error: 'missing session id'"
|
|
||||||
)
|
|
||||||
payment_request = data.get("url")
|
|
||||||
if not payment_request:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'missing payment URL'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=True, checking_id=session_id, payment_request=payment_request
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'invalid json response'"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||||
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
try:
|
try:
|
||||||
r = await self.client.get(
|
stripe_id = self._normalize_stripe_id(checking_id)
|
||||||
url=f"/v1/checkout/sessions/{checking_id}",
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
data = r.json()
|
if stripe_id.startswith("cs_"):
|
||||||
payment_status = data.get("payment_status")
|
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
|
||||||
if not payment_status:
|
r.raise_for_status()
|
||||||
return FiatPaymentPendingStatus()
|
return self._status_from_checkout_session(r.json())
|
||||||
if payment_status == "paid":
|
|
||||||
# todo: handle fee
|
|
||||||
return FiatPaymentSuccessStatus()
|
|
||||||
|
|
||||||
expires_at = data.get("expires_at")
|
if stripe_id.startswith("pi_"):
|
||||||
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
|
||||||
if expires_at and expires_at < _24_hours_ago.timestamp():
|
r.raise_for_status()
|
||||||
# be defensive: add a 24 hour buffer
|
return self._status_from_payment_intent(r.json())
|
||||||
return FiatPaymentFailedStatus()
|
|
||||||
|
|
||||||
|
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"Error getting invoice status: {exc}")
|
logger.debug(f"Error getting invoice status: {exc}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -243,6 +243,13 @@ body.body--dark .q-field--error .q-field__messages {
|
|||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blur-and-disable {
|
||||||
|
filter: blur(4px); /* blur entire element & children */
|
||||||
|
pointer-events: none; /* block mouse interaction */
|
||||||
|
user-select: none; /* prevent text selection */
|
||||||
|
opacity: 0.6; /* optional: faded look */
|
||||||
|
}
|
||||||
|
|
||||||
.lnbits__table-bordered td,
|
.lnbits__table-bordered td,
|
||||||
.lnbits__table-bordered th {
|
.lnbits__table-bordered th {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
|
|||||||
@@ -164,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',
|
||||||
@@ -420,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}',
|
||||||
@@ -489,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',
|
||||||
@@ -653,5 +665,7 @@ window.localisation.en = {
|
|||||||
callback_success_url_hint:
|
callback_success_url_hint:
|
||||||
'The user will be redirected to this URL after the payment is successful',
|
'The user will be redirected to this URL after the payment is successful',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
not_connected: 'Not Connected'
|
not_connected: 'Not Connected',
|
||||||
|
free: 'Free',
|
||||||
|
paid: 'Paid'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
return moment.utc(date * 1000).fromNow()
|
return moment
|
||||||
|
.utc(date * 1000)
|
||||||
|
.local()
|
||||||
|
.fromNow()
|
||||||
},
|
},
|
||||||
sendTestEmail() {
|
sendTestEmail() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
window.LNbits = {
|
window.LNbits = {
|
||||||
g: window.g,
|
g: window.g,
|
||||||
api: {
|
api: {
|
||||||
request(method, url, apiKey, data) {
|
request(method, url, apiKey, data, options = {}) {
|
||||||
return axios({
|
return axios({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': apiKey
|
'X-Api-Key': apiKey
|
||||||
},
|
},
|
||||||
data: data
|
data: data,
|
||||||
|
...options
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getServerHealth() {
|
getServerHealth() {
|
||||||
@@ -266,10 +267,10 @@ window.LNbits = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||||
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
|
||||||
|
|
||||||
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||||
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra?.tag
|
obj.tag = obj.extra?.tag
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
window.app.component('lnbits-data-fields', {
|
||||||
|
name: 'lnbits-data-fields',
|
||||||
|
template: '#lnbits-data-fields',
|
||||||
|
props: ['fields', 'hide-advanced'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
fieldTypes: [
|
||||||
|
{label: 'Text', value: 'str'},
|
||||||
|
{label: 'Integer', value: 'int'},
|
||||||
|
{label: 'Float', value: 'float'},
|
||||||
|
{label: 'Boolean', value: 'bool'},
|
||||||
|
{label: 'Date Time', value: 'datetime'},
|
||||||
|
{label: 'JSON', value: 'json'},
|
||||||
|
{label: 'Wallet Select', value: 'wallet'},
|
||||||
|
{label: 'Currency Select', value: 'currency'}
|
||||||
|
],
|
||||||
|
fieldsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Field Name',
|
||||||
|
field: 'name',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Type',
|
||||||
|
field: 'type',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'label',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Label',
|
||||||
|
field: 'label',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'hint',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Hint',
|
||||||
|
field: 'hint',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'optional',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Optional',
|
||||||
|
field: 'optional',
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
sortBy: 'name',
|
||||||
|
rowsPerPage: 100,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 100
|
||||||
|
},
|
||||||
|
search: null,
|
||||||
|
hideEmpty: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addField: function () {
|
||||||
|
this.fields.push({
|
||||||
|
name: 'field_name_' + (this.fields.length + 1),
|
||||||
|
type: 'text',
|
||||||
|
label: '',
|
||||||
|
hint: '',
|
||||||
|
optional: true,
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
editable: true,
|
||||||
|
fields: [] // For nested fields in JSON type
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeField: function (field) {
|
||||||
|
const index = this.fields.indexOf(field)
|
||||||
|
if (index > -1) {
|
||||||
|
this.fields.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
if (!this.hideAdvanced) {
|
||||||
|
this.fieldsTable.columns.push(
|
||||||
|
{
|
||||||
|
name: 'editable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'UI Editable',
|
||||||
|
field: 'editable',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sortable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Sortable',
|
||||||
|
field: 'sortable',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'searchable',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Searchable',
|
||||||
|
field: 'searchable',
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
window.ExtensionsBuilderPageLogic = {
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
step: 1,
|
||||||
|
previewStepNames: {
|
||||||
|
2: 'settings',
|
||||||
|
3: 'owner_data',
|
||||||
|
4: 'client_data',
|
||||||
|
5: 'public_page'
|
||||||
|
},
|
||||||
|
extensionDataCleanString: '',
|
||||||
|
extensionData: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
stub_version: '',
|
||||||
|
short_description: '',
|
||||||
|
description: '',
|
||||||
|
public_page: {
|
||||||
|
has_public_page: true,
|
||||||
|
owner_data_fields: {
|
||||||
|
name: '',
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
client_data_fields: {
|
||||||
|
public_inputs: []
|
||||||
|
},
|
||||||
|
action_fields: {
|
||||||
|
generate_action: true,
|
||||||
|
generate_payment_logic: false,
|
||||||
|
wallet_id: '',
|
||||||
|
currency: '',
|
||||||
|
amount: '',
|
||||||
|
paid_flag: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview_action: {
|
||||||
|
is_preview_mode: false,
|
||||||
|
is_settings_preview: false,
|
||||||
|
is_owner_data_preview: false,
|
||||||
|
is_client_data_preview: false,
|
||||||
|
is_public_page_preview: false
|
||||||
|
},
|
||||||
|
settings_data: {
|
||||||
|
name: 'Settings',
|
||||||
|
enabled: true,
|
||||||
|
type: 'user',
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
owner_data: {
|
||||||
|
name: 'OwnerData',
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
client_data: {
|
||||||
|
enabled: true,
|
||||||
|
name: 'ClientData',
|
||||||
|
fields: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sampleField: {
|
||||||
|
name: 'name',
|
||||||
|
type: 'str',
|
||||||
|
label: 'Name',
|
||||||
|
hint: '',
|
||||||
|
optional: true,
|
||||||
|
editable: true,
|
||||||
|
searchable: true,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
|
||||||
|
settingsTypes: [
|
||||||
|
{label: 'User Settings', value: 'user'},
|
||||||
|
{label: 'Admin Settings', value: 'admin'}
|
||||||
|
],
|
||||||
|
amountSource: [
|
||||||
|
{label: 'Client Data', value: 'client_data'},
|
||||||
|
{label: 'Owner Data', value: 'owner_data'}
|
||||||
|
],
|
||||||
|
extensionStubVersions: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'extensionData.public_page.action_fields.amount_source': function (
|
||||||
|
newVal,
|
||||||
|
oldVal
|
||||||
|
) {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
this.extensionData.public_page.action_fields.amount = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
paymentActionAmountFields() {
|
||||||
|
const amount_source =
|
||||||
|
this.extensionData.public_page.action_fields.amount_source
|
||||||
|
console.log('### amount_source:', amount_source)
|
||||||
|
if (!amount_source) return ['']
|
||||||
|
|
||||||
|
if (amount_source === 'owner_data') {
|
||||||
|
return [''].concat(
|
||||||
|
this.extensionData.owner_data.fields
|
||||||
|
.filter(f => f.type === 'int' || f.type === 'float')
|
||||||
|
.map(f => f.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (amount_source === 'client_data') {
|
||||||
|
return [''].concat(
|
||||||
|
this.extensionData.client_data.fields
|
||||||
|
.filter(f => f.type === 'int' || f.type === 'float')
|
||||||
|
.map(f => f.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveState() {
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
'lnbits.extension.builder.data',
|
||||||
|
JSON.stringify(this.extensionData)
|
||||||
|
)
|
||||||
|
this.$q.localStorage.set('lnbits.extension.builder.step', this.step)
|
||||||
|
},
|
||||||
|
nextStep() {
|
||||||
|
this.saveState()
|
||||||
|
this.$refs.stepper.next()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
previousStep() {
|
||||||
|
this.saveState()
|
||||||
|
this.$refs.stepper.previous()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
onStepChange() {
|
||||||
|
this.saveState()
|
||||||
|
this.refreshPreview()
|
||||||
|
},
|
||||||
|
clearAllData() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Are you sure you want to clear all data? This action cannot be undone.'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.extensionData = JSON.parse(this.extensionDataCleanString)
|
||||||
|
this.$q.localStorage.remove('lnbits.extension.builder.data')
|
||||||
|
this.$refs.stepper.set(1)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportJsonData() {
|
||||||
|
const status = Quasar.exportFile(
|
||||||
|
`${this.extensionData.id || 'data-export'}.json`,
|
||||||
|
JSON.stringify(this.extensionData, null, 2),
|
||||||
|
'text/json'
|
||||||
|
)
|
||||||
|
if (status !== true) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'Browser denied file download...',
|
||||||
|
color: 'negative',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'File downloaded!',
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'file_download'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJsonDataInput(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = e => {
|
||||||
|
this.extensionData = {
|
||||||
|
...this.extensionData,
|
||||||
|
...JSON.parse(e.target.result)
|
||||||
|
}
|
||||||
|
this.$refs.extensionDataInput.value = null
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: 'File loaded!',
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'file_upload'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
},
|
||||||
|
async buildExtension() {
|
||||||
|
try {
|
||||||
|
const options = {responseType: 'blob'}
|
||||||
|
const response = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/zip',
|
||||||
|
null,
|
||||||
|
this.extensionData,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
// download the zip file
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${this.extensionData.id || 'lnbits-extension'}.zip`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async buildExtensionAndDeploy() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/deploy',
|
||||||
|
null,
|
||||||
|
this.extensionData
|
||||||
|
)
|
||||||
|
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: data.message || 'Extension deployed!',
|
||||||
|
color: 'positive'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cleanCacheData() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Are you sure you want to clean the cache data? This action cannot be undone.',
|
||||||
|
'Clean Cache Data'
|
||||||
|
)
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/api/v1/extension/builder',
|
||||||
|
null,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
Quasar.Notify.create({
|
||||||
|
message: data.message || 'Cache data cleaned!',
|
||||||
|
color: 'positive'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async previewExtension(previewPageName) {
|
||||||
|
this.saveState()
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/extension/builder/preview',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
...this.extensionData,
|
||||||
|
...{
|
||||||
|
preview_action: {
|
||||||
|
is_preview_mode: !!previewPageName,
|
||||||
|
is_settings_preview: previewPageName === 'settings',
|
||||||
|
is_owner_data_preview: previewPageName === 'owner_data',
|
||||||
|
is_client_data_preview: previewPageName === 'client_data',
|
||||||
|
is_public_page_preview: previewPageName === 'public_page'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.refreshIframe(previewPageName)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshPreview() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const stepName = this.previewStepNames[`${this.step}`] || ''
|
||||||
|
if (!stepName) return
|
||||||
|
this.previewExtension(stepName)
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
async getStubExtensionReleases() {
|
||||||
|
try {
|
||||||
|
const stub_ext_id = 'extension_builder_stub'
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/extension/${stub_ext_id}/releases`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.extensionStubVersions = data.sort((a, b) =>
|
||||||
|
a.version < b.version ? 1 : -1
|
||||||
|
)
|
||||||
|
this.extensionData.stub_version = this.extensionStubVersions[0]
|
||||||
|
? this.extensionStubVersions[0].version
|
||||||
|
: ''
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshIframe(previewPageName = '') {
|
||||||
|
const iframe = this.$refs[`iframeStep${this.step}`]
|
||||||
|
if (!iframe) {
|
||||||
|
console.warn('Extension Builder Preview iframe not loaded yet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iframe.onload = () => {
|
||||||
|
const iframeDoc =
|
||||||
|
iframe.contentDocument || iframe.contentWindow.document
|
||||||
|
|
||||||
|
iframeDoc.body.style.transform = 'scale(0.8)'
|
||||||
|
iframeDoc.body.style.transformOrigin = 'center top'
|
||||||
|
}
|
||||||
|
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
|
||||||
|
},
|
||||||
|
initBasicData() {
|
||||||
|
this.extensionData.owner_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionData.client_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionData.settings_data.fields = [
|
||||||
|
JSON.parse(JSON.stringify(this.sampleField))
|
||||||
|
]
|
||||||
|
this.extensionDataCleanString = JSON.stringify(this.extensionData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.initBasicData()
|
||||||
|
|
||||||
|
const extensionData = this.$q.localStorage.getItem(
|
||||||
|
'lnbits.extension.builder.data'
|
||||||
|
)
|
||||||
|
if (extensionData) {
|
||||||
|
this.extensionData = {...this.extensionData, ...JSON.parse(extensionData)}
|
||||||
|
}
|
||||||
|
const step = +this.$q.localStorage.getItem('lnbits.extension.builder.step')
|
||||||
|
if (step) {
|
||||||
|
this.step = step
|
||||||
|
}
|
||||||
|
if (this.g.user.admin) {
|
||||||
|
this.getStubExtensionReleases()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.refreshIframe()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
mixins: [windowMixin]
|
||||||
|
}
|
||||||
@@ -184,6 +184,15 @@ const routes = [
|
|||||||
scripts: ['/static/js/extensions.js']
|
scripts: ['/static/js/extensions.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/extensions/builder',
|
||||||
|
name: 'ExtensionsBuilder',
|
||||||
|
component: DynamicComponent,
|
||||||
|
props: {
|
||||||
|
fetchUrl: '/extensions/builder',
|
||||||
|
scripts: ['/static/js/extensions_builder.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/account',
|
path: '/account',
|
||||||
name: 'Account',
|
name: 'Account',
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
|||||||
return LNbits.utils.formatDate(this.ts)
|
return LNbits.utils.formatDate(this.ts)
|
||||||
},
|
},
|
||||||
dateFrom() {
|
dateFrom() {
|
||||||
return moment.utc(this.date).fromNow()
|
return moment.utc(this.date).local().fromNow()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
|
|||||||
if (p.extra && p.extra.tag) {
|
if (p.extra && p.extra.tag) {
|
||||||
p.tag = p.extra.tag
|
p.tag = p.extra.tag
|
||||||
}
|
}
|
||||||
p.timeFrom = moment.utc(p.created_at).fromNow()
|
p.timeFrom = moment.utc(p.created_at).local().fromNow()
|
||||||
p.outgoing = p.amount < 0
|
p.outgoing = p.amount < 0
|
||||||
p.amount =
|
p.amount =
|
||||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||||
|
|||||||
@@ -179,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
|
||||||
@@ -476,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Vendored
+427
-305
File diff suppressed because it is too large
Load Diff
@@ -649,8 +649,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showButtons"
|
v-if="showButtons"
|
||||||
class="qrcode__buttons row q-gutter-x-sm"
|
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
|
||||||
style="justify-content: flex-end"
|
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="nfc && nfcSupported"
|
v-if="nfc && nfcSupported"
|
||||||
@@ -1671,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+5
-5
@@ -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",
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@
|
|||||||
"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"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "1.3.0-rc7"
|
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" }]
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1260,7 +1260,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "1.3.0rc7"
|
version = "1.3.0rc8"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user