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/
|
||||
|
||||
######################################
|
||||
###### .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 ##########
|
||||
######################################
|
||||
# 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
|
||||
# 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"
|
||||
@@ -16,14 +100,6 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
########### 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
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
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_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
########## 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
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# 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
|
||||
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
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||
@@ -210,8 +273,7 @@ LNBITS_BLOCKED_IPS=""
|
||||
# if set new users will not be able to create accounts
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# ID of the super user. The user ID must exist.
|
||||
# SUPER_USER=""
|
||||
|
||||
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||
@@ -244,26 +306,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
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)
|
||||
LNBITS_SERVICE_FEE=0.0
|
||||
# the wallet where fees go to
|
||||
@@ -292,16 +334,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
###### 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:
|
||||
release:
|
||||
@@ -11,56 +11,89 @@ jobs:
|
||||
steps:
|
||||
# Step 1: Checkout the repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Step 2: Install Dependencies
|
||||
- name: Install Dependencies
|
||||
# Step 2: Set up Python (uv will still use this toolchain)
|
||||
- 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: |
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
shell: bash
|
||||
|
||||
# Step 3: Clone LNbits Repository
|
||||
- name: Clone LNbits
|
||||
# Optional: Cache uv + venv to speed up CI
|
||||
- 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: |
|
||||
mv .github/packaging packaging
|
||||
mkdir -p packaging/linux/AppDir/usr
|
||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||
shell: bash
|
||||
|
||||
# Step 4: Make the AppImage Asset
|
||||
- name: Make Asset
|
||||
# Step 5: Build the LNbits binary with uv + PyInstaller
|
||||
- name: Build LNbits binary (uv + PyInstaller)
|
||||
run: |
|
||||
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
|
||||
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 ../../../../..
|
||||
chmod +x packaging/linux/AppDir/AppRun
|
||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||
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 \
|
||||
! -name 'dist' \
|
||||
! -name 'lnbits' \
|
||||
-exec rm -rf {} +
|
||||
|
||||
# Build AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ github.event.release.tag_name }}
|
||||
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"
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
# Step 5: Upload Linux Release Asset
|
||||
# Step 6: Upload Linux Release Asset
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
@@ -69,4 +102,4 @@ jobs:
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
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:
|
||||
|
||||
```sh
|
||||
sudo apt-get install 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
|
||||
sudo apt-get install jq libfuse2
|
||||
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
|
||||
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
|
||||
# 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:
|
||||
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
|
||||
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
|
||||
# .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
|
||||
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));
|
||||
};
|
||||
|
||||
# System-specific nixos modules to avoid circular dependency
|
||||
nixosModules.default = { pkgs, lib, config, ... }: {
|
||||
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
||||
nixpkgs.overlays = [ self.overlays.default ];
|
||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
||||
};
|
||||
|
||||
checks = { };
|
||||
|
||||
@@ -7,6 +7,7 @@ from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.extensions_builder_api import extension_builder_router
|
||||
from .views.fiat_api import fiat_router
|
||||
|
||||
# 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(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(extension_builder_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(payment_router)
|
||||
|
||||
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
|
||||
info_notification: str | None
|
||||
critical_notification: str | None
|
||||
details_link: str | None
|
||||
paid_features: str | None
|
||||
pay_link: str | None
|
||||
|
||||
def is_version_compatible(self):
|
||||
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
|
||||
icon: str | None = None
|
||||
details_link: str | None = None
|
||||
|
||||
paid_features: str | None = None
|
||||
pay_link: str | None = None
|
||||
cost_sats: int | None = None
|
||||
paid_sats: int | None = 0
|
||||
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
pay_link=e.pay_link,
|
||||
paid_features=e.paid_features,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
@@ -409,7 +415,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
@@ -452,9 +457,23 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
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:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
meta = self.meta or ExtensionMeta()
|
||||
meta.latest_release = release
|
||||
@@ -465,13 +484,19 @@ class InstallableExtension(BaseModel):
|
||||
):
|
||||
self.meta.latest_release = 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_payment_link(self, release: ExtensionRelease | None):
|
||||
if not release:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
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):
|
||||
if (
|
||||
@@ -597,7 +622,7 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
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
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
@@ -611,10 +636,10 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == e.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(release)
|
||||
existing_ext.check_release_updates(release)
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.check_latest_version(release)
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
@@ -628,8 +653,11 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||
extension_releases: list[ExtensionRelease] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
all_manifests = [
|
||||
*settings.lnbits_extensions_manifests,
|
||||
settings.lnbits_extensions_builder_manifest_url,
|
||||
]
|
||||
for url in all_manifests:
|
||||
try:
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -35,7 +37,8 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
if installed_ext and installed_ext.meta:
|
||||
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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
currency=invoice_data.unit,
|
||||
memo=invoice_data.memo,
|
||||
extra=invoice_data.extra or {},
|
||||
)
|
||||
|
||||
if fiat_invoice.failed:
|
||||
logger.warning(fiat_invoice.error_message)
|
||||
internal_payment.status = PaymentState.FAILED
|
||||
|
||||
@@ -84,6 +84,27 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
</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-section>
|
||||
<q-item-label>
|
||||
@@ -105,6 +126,17 @@
|
||||
</q-item>
|
||||
<br />
|
||||
</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>
|
||||
</q-card-section>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
<q-space></q-space>
|
||||
|
||||
<q-input
|
||||
:label="$t('search_extensions')"
|
||||
:dense="dense"
|
||||
@@ -53,6 +54,18 @@
|
||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||
></span>
|
||||
</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
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
@@ -124,8 +137,40 @@
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div>
|
||||
<div style="justify-content: space-between; display: flex">
|
||||
<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 style="justify-content: space-between; display: flex">
|
||||
<q-toggle
|
||||
@@ -903,7 +948,6 @@
|
||||
:href="selectedExtensionDetails.repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="q-pr-xs"
|
||||
><q-tooltip>repository</q-tooltip></q-btn
|
||||
>
|
||||
</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 bolt11 import decode as bolt11_decode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
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
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
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 fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.services.fiat_providers import test_connection
|
||||
from lnbits.decorators import check_admin
|
||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||
|
||||
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:
|
||||
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 pathlib import Path
|
||||
from typing import Annotated
|
||||
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
|
||||
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": (
|
||||
dict(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(),
|
||||
"extension_data": extension_data,
|
||||
"extension_builder_enabled": user.admin
|
||||
or settings.lnbits_extensions_builder_activate_non_admins,
|
||||
"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(
|
||||
"/wallet",
|
||||
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}
|
||||
|
||||
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:
|
||||
status = await payment.check_status()
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator, Coroutine
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -106,6 +106,7 @@ class FiatProvider(ABC):
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||
pass
|
||||
|
||||
+223
-62
@@ -2,10 +2,12 @@ import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
@@ -21,6 +23,34 @@ from .base import (
|
||||
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):
|
||||
"""https://docs.stripe.com/api"""
|
||||
@@ -30,9 +60,9 @@ class StripeWallet(FiatProvider):
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.stripe_api_endpoint:
|
||||
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
||||
|
||||
if not settings.stripe_api_secret_key:
|
||||
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
||||
@@ -60,8 +90,10 @@ class StripeWallet(FiatProvider):
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
available_balance = data.get("available", [{}])[0].get("amount", 0)
|
||||
# pending_balance = data.get("pending", {}).get("amount", 0)
|
||||
available = data.get("available") or []
|
||||
available_balance = 0
|
||||
if available and isinstance(available, list):
|
||||
available_balance = int(available[0].get("amount", 0))
|
||||
|
||||
return FiatStatusResponse(balance=available_balance)
|
||||
except json.JSONDecodeError:
|
||||
@@ -76,81 +108,47 @@ class StripeWallet(FiatProvider):
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> FiatInvoiceResponse:
|
||||
amount_cents = int(amount * 100)
|
||||
form_data = [
|
||||
("mode", "payment"),
|
||||
(
|
||||
"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)
|
||||
opts = self._parse_create_opts(extra or {})
|
||||
if not opts:
|
||||
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
|
||||
|
||||
try:
|
||||
headers = self.headers.copy()
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
r = await self.client.post(
|
||||
url="/v1/checkout/sessions", headers=headers, content=encoded_data
|
||||
if opts.fiat_method == "checkout":
|
||||
return await self._create_checkout_invoice(
|
||||
amount_cents, currency, payment_hash, memo, opts
|
||||
)
|
||||
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")
|
||||
if not session_id:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing session id'"
|
||||
)
|
||||
payment_request = data.get("url")
|
||||
if not payment_request:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing payment URL'"
|
||||
)
|
||||
|
||||
return FiatInvoiceResponse(
|
||||
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}."
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
|
||||
)
|
||||
|
||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
r = await self.client.get(
|
||||
url=f"/v1/checkout/sessions/{checking_id}",
|
||||
)
|
||||
r.raise_for_status()
|
||||
stripe_id = self._normalize_stripe_id(checking_id)
|
||||
|
||||
data = r.json()
|
||||
payment_status = data.get("payment_status")
|
||||
if not payment_status:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment_status == "paid":
|
||||
# todo: handle fee
|
||||
return FiatPaymentSuccessStatus()
|
||||
if stripe_id.startswith("cs_"):
|
||||
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
|
||||
r.raise_for_status()
|
||||
return self._status_from_checkout_session(r.json())
|
||||
|
||||
expires_at = data.get("expires_at")
|
||||
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
if expires_at and expires_at < _24_hours_ago.timestamp():
|
||||
# be defensive: add a 24 hour buffer
|
||||
return FiatPaymentFailedStatus()
|
||||
if stripe_id.startswith("pi_"):
|
||||
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
|
||||
r.raise_for_status()
|
||||
return self._status_from_payment_intent(r.json())
|
||||
|
||||
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
@@ -167,6 +165,169 @@ class StripeWallet(FiatProvider):
|
||||
value = await mock_queue.get()
|
||||
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:
|
||||
return "-".join(
|
||||
[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:
|
||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||
folders = [
|
||||
"lnbits/templates",
|
||||
"lnbits/core/templates",
|
||||
settings.extension_builder_working_dir_path.as_posix(),
|
||||
]
|
||||
|
||||
if additional_folders:
|
||||
additional_folders += [
|
||||
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
|
||||
)
|
||||
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_user_default_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
||||
lnbits_extensions_manifests: list[str] = Field(
|
||||
default=[
|
||||
"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):
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
.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 th {
|
||||
border: 1px solid black;
|
||||
|
||||
@@ -164,8 +164,12 @@ window.localisation.en = {
|
||||
featured: 'Featured',
|
||||
all: 'All',
|
||||
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',
|
||||
new_version: 'New Version',
|
||||
extension_has_free_release: 'Has free releases',
|
||||
extension_has_paid_release: 'Has paid releases',
|
||||
extension_depends_on: 'Depends on:',
|
||||
extension_rating_soon: 'Ratings coming soon',
|
||||
extension_installed_version: 'Installed version',
|
||||
@@ -420,6 +424,7 @@ window.localisation.en = {
|
||||
admin_settings: 'Admin Settings',
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
create_extension: 'Create Extension',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
pay_with: 'Pay with {provider}',
|
||||
@@ -489,9 +494,16 @@ window.localisation.en = {
|
||||
user_default_extensions_label: 'User extensions',
|
||||
user_default_extensions_hint:
|
||||
'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',
|
||||
misc_disable_extensions: 'Disable 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_label: 'Hides wallet api, extensions can choose to honor',
|
||||
wallets_management: 'Wallets Management',
|
||||
@@ -653,5 +665,7 @@ window.localisation.en = {
|
||||
callback_success_url_hint:
|
||||
'The user will be redirected to this URL after the payment is successful',
|
||||
connected: 'Connected',
|
||||
not_connected: 'Not Connected'
|
||||
not_connected: 'Not Connected',
|
||||
free: 'Free',
|
||||
paid: 'Paid'
|
||||
}
|
||||
|
||||
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment.utc(date * 1000).fromNow()
|
||||
return moment
|
||||
.utc(date * 1000)
|
||||
.local()
|
||||
.fromNow()
|
||||
},
|
||||
sendTestEmail() {
|
||||
LNbits.api
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
window.LNbits = {
|
||||
g: window.g,
|
||||
api: {
|
||||
request(method, url, apiKey, data) {
|
||||
request(method, url, apiKey, data, options = {}) {
|
||||
return axios({
|
||||
method: method,
|
||||
url: url,
|
||||
headers: {
|
||||
'X-Api-Key': apiKey
|
||||
},
|
||||
data: data
|
||||
data: data,
|
||||
...options
|
||||
})
|
||||
},
|
||||
getServerHealth() {
|
||||
@@ -266,10 +267,10 @@ window.LNbits = {
|
||||
}
|
||||
|
||||
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.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
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']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/extensions/builder',
|
||||
name: 'ExtensionsBuilder',
|
||||
component: DynamicComponent,
|
||||
props: {
|
||||
fetchUrl: '/extensions/builder',
|
||||
scripts: ['/static/js/extensions_builder.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
|
||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
||||
return LNbits.utils.formatDate(this.ts)
|
||||
},
|
||||
dateFrom() {
|
||||
return moment.utc(this.date).fromNow()
|
||||
return moment.utc(this.date).local().fromNow()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
|
||||
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
|
||||
if (p.extra && 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.amount =
|
||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||
|
||||
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
|
||||
methods: {
|
||||
dateFromNow(unix) {
|
||||
const date = new Date(unix * 1000)
|
||||
return moment.utc(date).fromNow()
|
||||
return moment.utc(date).local().fromNow()
|
||||
},
|
||||
formatFiatAmount(amount, currency) {
|
||||
this.update.currency = currency
|
||||
@@ -476,8 +476,14 @@ window.WalletPageLogic = {
|
||||
createdDate,
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
)
|
||||
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
||||
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
||||
cleanInvoice.expireDateFrom = moment
|
||||
.utc(expireDate)
|
||||
.local()
|
||||
.fromNow()
|
||||
cleanInvoice.createdDateFrom = moment
|
||||
.utc(createdDate)
|
||||
.local()
|
||||
.fromNow()
|
||||
|
||||
cleanInvoice.expired = false // TODO
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ body.body--dark .q-field--error {
|
||||
.lnbits__dialog-card {
|
||||
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 th {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"js/components/lnbits-qrcode-lnurl.js",
|
||||
"js/components/lnbits-funding-sources.js",
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components/payment-list.js",
|
||||
"js/components.js",
|
||||
"js/init-app.js"
|
||||
|
||||
Vendored
+427
-305
File diff suppressed because it is too large
Load Diff
@@ -649,8 +649,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="showButtons"
|
||||
class="qrcode__buttons row q-gutter-x-sm"
|
||||
style="justify-content: flex-end"
|
||||
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
|
||||
>
|
||||
<q-btn
|
||||
v-if="nfc && nfcSupported"
|
||||
@@ -1671,3 +1670,189 @@
|
||||
<q-separator class="col q-ml-sm"></q-separator>
|
||||
</div>
|
||||
</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;
|
||||
default = "/var/lib/lnbits";
|
||||
description = ''
|
||||
The lnbits state directory which LNBITS_DATA_FOLDER will be set to
|
||||
The lnbits state directory
|
||||
'';
|
||||
};
|
||||
host = mkOption {
|
||||
@@ -90,6 +90,7 @@ in
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/data 0700 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.lnbits = {
|
||||
@@ -99,18 +100,18 @@ in
|
||||
after = [ "network-online.target" ];
|
||||
environment = lib.mkMerge [
|
||||
{
|
||||
LNBITS_DATA_FOLDER = "${cfg.stateDir}";
|
||||
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions";
|
||||
LNBITS_PATH = "${cfg.package.src}";
|
||||
LNBITS_DATA_FOLDER = "${cfg.stateDir}/data";
|
||||
# LNBits automatically appends '/extensions' to this path
|
||||
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}";
|
||||
}
|
||||
cfg.env
|
||||
];
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = "${cfg.package.src}";
|
||||
StateDirectory = "${cfg.stateDir}";
|
||||
ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}";
|
||||
WorkingDirectory = "${cfg.package}/lib/python3.12/site-packages";
|
||||
StateDirectory = "lnbits";
|
||||
ExecStart = "${cfg.package}/bin/lnbits --port ${toString cfg.port} --host ${cfg.host}";
|
||||
Restart = "always";
|
||||
PrivateTmp = true;
|
||||
};
|
||||
|
||||
Generated
+5
-5
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "lnbits",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"moment": "^2.30.1",
|
||||
"nostr-tools": "^2.7.2",
|
||||
@@ -402,13 +402,13 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
||||
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@
|
||||
"sass": "^1.78.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"moment": "^2.30.1",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
@@ -94,6 +94,7 @@
|
||||
"js/components/lnbits-qrcode-lnurl.js",
|
||||
"js/components/lnbits-funding-sources.js",
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components/payment-list.js",
|
||||
"js/components.js",
|
||||
"js/init-app.js"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.3.0-rc7"
|
||||
version = "1.3.0-rc8"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
@@ -250,6 +250,7 @@ classmethod-decorators = [
|
||||
# TODO: remove S101 ignore
|
||||
"lnbits/*" = ["S101"]
|
||||
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
|
||||
"lnbits/core/services/extensions_builder.py" = ["S701"]
|
||||
"crypto.py" = ["S324"]
|
||||
"test*.py" = ["S101", "S105", "S106", "S307"]
|
||||
"tools*.py" = ["S101", "S608"]
|
||||
|
||||
Reference in New Issue
Block a user