Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769d3a07e8 | ||
|
|
b3dfb0384e |
+59
-88
@@ -1,95 +1,11 @@
|
|||||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
######################################
|
|
||||||
###### .env ONLY SETTINGS ############
|
|
||||||
######################################
|
|
||||||
# The following settings are ONLY set in your .env file.
|
|
||||||
# They are NOT managed by the Admin UI and are not stored in the database.
|
|
||||||
|
|
||||||
# === Logging and Development ===
|
|
||||||
|
|
||||||
DEBUG=False
|
|
||||||
DEBUG_DATABASE=False
|
|
||||||
BUNDLE_ASSETS=True
|
|
||||||
|
|
||||||
# logging into LNBITS_DATA_FOLDER/logs/
|
|
||||||
ENABLE_LOG_TO_FILE=true
|
|
||||||
|
|
||||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
|
||||||
LOG_ROTATION="100 MB"
|
|
||||||
LOG_RETENTION="3 months"
|
|
||||||
|
|
||||||
# for database cleanup commands
|
|
||||||
# CLEANUP_WALLETS_DAYS=90
|
|
||||||
|
|
||||||
# === Admin Settings ===
|
|
||||||
|
|
||||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
|
||||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
|
||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
|
||||||
# The rest of the settings will be stored in your database and you will be able to change them
|
|
||||||
# only through the Admin UI.
|
|
||||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
|
||||||
LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
HOST=127.0.0.1
|
|
||||||
PORT=5000
|
|
||||||
# VERSION=
|
|
||||||
# USER_AGENT=
|
|
||||||
|
|
||||||
# === LNbits ===
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
|
||||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
|
||||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
|
||||||
# psycopg2 as an additional dependency
|
|
||||||
LNBITS_DATA_FOLDER="./data"
|
|
||||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|
||||||
|
|
||||||
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
|
||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
|
||||||
FORWARDED_ALLOW_IPS="*"
|
|
||||||
|
|
||||||
# Path where extensions will be installed (defaults to `./lnbits/`).
|
|
||||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
|
||||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
|
||||||
|
|
||||||
# ID of the super user. The user ID must exist.
|
|
||||||
# SUPER_USER=""
|
|
||||||
|
|
||||||
# LNBITS_TITLE="LNbits API"
|
|
||||||
# LNBITS_PATH="folder/path"
|
|
||||||
|
|
||||||
# === Auth Configurations ===
|
|
||||||
|
|
||||||
# Secret Key: will default to the hash of the super user.
|
|
||||||
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
|
||||||
AUTH_SECRET_KEY=""
|
|
||||||
|
|
||||||
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
|
||||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
|
||||||
|
|
||||||
######################################
|
|
||||||
###### END .env ONLY SETTINGS ########
|
|
||||||
######################################
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
####### Auth Configurations ##########
|
####### Auth Configurations ##########
|
||||||
######################################
|
######################################
|
||||||
|
# Secret Key: will default to the hash of the super user.
|
||||||
|
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
||||||
|
AUTH_SECRET_KEY=""
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
@@ -100,6 +16,14 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
|||||||
########### Admin Settings ###########
|
########### Admin Settings ###########
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
|
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||||
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||||
@@ -108,14 +32,22 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f
|
|||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
########## Funding Source ############
|
########## Funding Source ############
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# which fundingsources are allowed in the admin ui
|
||||||
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
|
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||||
|
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||||
|
|
||||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||||
LIGHTNING_INVOICE_EXPIRY=3600
|
LIGHTNING_INVOICE_EXPIRY=3600
|
||||||
@@ -263,6 +195,11 @@ KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
|||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# uvicorn variable, allow https behind a proxy
|
||||||
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
|
FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
@@ -273,7 +210,8 @@ LNBITS_BLOCKED_IPS=""
|
|||||||
# if set new users will not be able to create accounts
|
# if set new users will not be able to create accounts
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
|
# ID of the super user. The user ID must exist.
|
||||||
|
# SUPER_USER=""
|
||||||
|
|
||||||
# Extensions only admin can access
|
# Extensions only admin can access
|
||||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||||
@@ -306,6 +244,26 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
|||||||
# Hides wallet api, extensions can choose to honor
|
# Hides wallet api, extensions can choose to honor
|
||||||
LNBITS_HIDE_API=false
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
# the service fee (in percent)
|
# the service fee (in percent)
|
||||||
LNBITS_SERVICE_FEE=0.0
|
LNBITS_SERVICE_FEE=0.0
|
||||||
# the wallet where fees go to
|
# the wallet where fees go to
|
||||||
@@ -334,3 +292,16 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
|||||||
###### Logging and Development #######
|
###### Logging and Development #######
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
DEBUG=false
|
||||||
|
DEBUG_DATABASE=false
|
||||||
|
BUNDLE_ASSETS=true
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
|
|||||||
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get install jq libfuse2
|
sudo apt-get install 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
|
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
|
||||||
chmod +x LNbits-latest.AppImage
|
chmod +x LNbits-latest.AppImage
|
||||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||||
```
|
```
|
||||||
@@ -132,18 +132,10 @@ Now visit `0.0.0.0:5000` to make a super-user account.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||||
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
|
sh <(c&url -L https://nixos.org/nix/install) --daemon
|
||||||
|
|
||||||
# Enable nix-command and flakes experimental features for nix:
|
# Enable nix-command and flakes experimental features for nix:
|
||||||
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
|
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
||||||
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
|
|
||||||
|
|
||||||
# Add user to Nix
|
|
||||||
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
|
|
||||||
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
|
|
||||||
|
|
||||||
# Restart daemon so changes apply
|
|
||||||
sudo systemctl restart nix-daemon
|
|
||||||
|
|
||||||
# Add cachix for cached binaries
|
# Add cachix for cached binaries
|
||||||
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
@@ -168,7 +160,7 @@ but you can also set the env variables or pass command line arguments:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
|
|
||||||
# Once you have created a user, you can set as the super_user
|
# Once you have created a user, you can set as the super_user
|
||||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
|
|||||||
@@ -139,10 +139,9 @@
|
|||||||
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
|
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
|
||||||
};
|
};
|
||||||
|
|
||||||
# System-specific nixos modules to avoid circular dependency
|
|
||||||
nixosModules.default = { pkgs, lib, config, ... }: {
|
nixosModules.default = { pkgs, lib, config, ... }: {
|
||||||
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
||||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
nixpkgs.overlays = [ self.overlays.default ];
|
||||||
};
|
};
|
||||||
|
|
||||||
checks = { };
|
checks = { };
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from .views.audit_api import audit_router
|
|||||||
from .views.auth_api import auth_router
|
from .views.auth_api import auth_router
|
||||||
from .views.callback_api import callback_router
|
from .views.callback_api import callback_router
|
||||||
from .views.extension_api import extension_router
|
from .views.extension_api import extension_router
|
||||||
from .views.extensions_builder_api import extension_builder_router
|
|
||||||
from .views.fiat_api import fiat_router
|
from .views.fiat_api import fiat_router
|
||||||
|
|
||||||
# this compat is needed for usermanager extension
|
# this compat is needed for usermanager extension
|
||||||
@@ -32,7 +31,6 @@ def init_core_routers(app: FastAPI):
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(node_router)
|
app.include_router(node_router)
|
||||||
app.include_router(extension_router)
|
app.include_router(extension_router)
|
||||||
app.include_router(extension_builder_router)
|
|
||||||
app.include_router(super_node_router)
|
app.include_router(super_node_router)
|
||||||
app.include_router(public_node_router)
|
app.include_router(public_node_router)
|
||||||
app.include_router(payment_router)
|
app.include_router(payment_router)
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ExplicitRelease(BaseModel):
|
|||||||
info_notification: str | None
|
info_notification: str | None
|
||||||
critical_notification: str | None
|
critical_notification: str | None
|
||||||
details_link: str | None
|
details_link: str | None
|
||||||
paid_features: str | None
|
|
||||||
pay_link: str | None
|
pay_link: str | None
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
@@ -188,7 +187,6 @@ class ExtensionRelease(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
details_link: str | None = None
|
details_link: str | None = None
|
||||||
|
|
||||||
paid_features: str | None = None
|
|
||||||
pay_link: str | None = None
|
pay_link: str | None = None
|
||||||
cost_sats: int | None = None
|
cost_sats: int | None = None
|
||||||
paid_sats: int | None = 0
|
paid_sats: int | None = 0
|
||||||
@@ -258,7 +256,6 @@ class ExtensionRelease(BaseModel):
|
|||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
details_link=e.details_link,
|
details_link=e.details_link,
|
||||||
pay_link=e.pay_link,
|
pay_link=e.pay_link,
|
||||||
paid_features=e.paid_features,
|
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
@@ -311,9 +308,6 @@ class ExtensionMeta(BaseModel):
|
|||||||
dependencies: list[str] = []
|
dependencies: list[str] = []
|
||||||
archive: str | None = None
|
archive: str | None = None
|
||||||
featured: bool = False
|
featured: bool = False
|
||||||
paid_features: str | None = None
|
|
||||||
has_paid_release: bool = False
|
|
||||||
has_free_release: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class InstallableExtension(BaseModel):
|
class InstallableExtension(BaseModel):
|
||||||
@@ -415,6 +409,7 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||||
shutil.rmtree(tmp_dir, True)
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||||
zip_ref.extractall(tmp_dir)
|
zip_ref.extractall(tmp_dir)
|
||||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||||
@@ -457,23 +452,9 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||||
|
|
||||||
def check_release_updates(self, release: ExtensionRelease | None):
|
def check_latest_version(self, release: ExtensionRelease | None):
|
||||||
self._check_latest_version(release)
|
|
||||||
self._check_payment_link(release)
|
|
||||||
|
|
||||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
|
||||||
if not pay_link or not self.meta or not self.meta.payments:
|
|
||||||
return None
|
|
||||||
return next(
|
|
||||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _check_latest_version(self, release: ExtensionRelease | None):
|
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
if not release.is_version_compatible:
|
|
||||||
return
|
|
||||||
if not self.meta or not self.meta.latest_release:
|
if not self.meta or not self.meta.latest_release:
|
||||||
meta = self.meta or ExtensionMeta()
|
meta = self.meta or ExtensionMeta()
|
||||||
meta.latest_release = release
|
meta.latest_release = release
|
||||||
@@ -484,19 +465,13 @@ class InstallableExtension(BaseModel):
|
|||||||
):
|
):
|
||||||
self.meta.latest_release = release
|
self.meta.latest_release = release
|
||||||
|
|
||||||
def _check_payment_link(self, release: ExtensionRelease | None):
|
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||||
if not release:
|
if not pay_link or not self.meta or not self.meta.payments:
|
||||||
return
|
return None
|
||||||
if not release.is_version_compatible:
|
return next(
|
||||||
return
|
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||||
if not self.meta:
|
None,
|
||||||
self.meta = ExtensionMeta()
|
)
|
||||||
if release.pay_link:
|
|
||||||
self.meta.has_paid_release = True
|
|
||||||
else:
|
|
||||||
self.meta.has_free_release = True
|
|
||||||
if release.paid_features:
|
|
||||||
self.meta.paid_features = release.paid_features
|
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
def _restore_payment_info(self):
|
||||||
if (
|
if (
|
||||||
@@ -622,7 +597,7 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == r.id), None
|
(ee for ee in extension_list if ee.id == r.id), None
|
||||||
)
|
)
|
||||||
if existing_ext and ext.meta:
|
if existing_ext and ext.meta:
|
||||||
existing_ext.check_release_updates(ext.meta.latest_release)
|
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
@@ -636,10 +611,10 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == e.id), None
|
(ee for ee in extension_list if ee.id == e.id), None
|
||||||
)
|
)
|
||||||
if existing_ext:
|
if existing_ext:
|
||||||
existing_ext.check_release_updates(release)
|
existing_ext.check_latest_version(release)
|
||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_release_updates(release)
|
ext.check_latest_version(release)
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
meta.featured = ext.id in manifest.featured
|
meta.featured = ext.id in manifest.featured
|
||||||
ext.meta = meta
|
ext.meta = meta
|
||||||
@@ -653,11 +628,8 @@ class InstallableExtension(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||||
extension_releases: list[ExtensionRelease] = []
|
extension_releases: list[ExtensionRelease] = []
|
||||||
all_manifests = [
|
|
||||||
*settings.lnbits_extensions_manifests,
|
for url in settings.lnbits_extensions_manifests:
|
||||||
settings.lnbits_extensions_builder_manifest_url,
|
|
||||||
]
|
|
||||||
for url in all_manifests:
|
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
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,9 +21,7 @@ from lnbits.settings import settings
|
|||||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||||
|
|
||||||
|
|
||||||
async def install_extension(
|
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
|
||||||
) -> Extension:
|
|
||||||
|
|
||||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||||
|
|
||||||
@@ -37,8 +35,7 @@ async def install_extension(
|
|||||||
if installed_ext and installed_ext.meta:
|
if installed_ext and installed_ext.meta:
|
||||||
ext_info.meta.payments = installed_ext.meta.payments
|
ext_info.meta.payments = installed_ext.meta.payments
|
||||||
|
|
||||||
if not skip_download:
|
await ext_info.download_archive()
|
||||||
await ext_info.download_archive()
|
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -9,6 +9,10 @@ from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
|||||||
from lnurl import execute_withdraw as lnurl_withdraw
|
from lnurl import execute_withdraw as lnurl_withdraw
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from bolt11.exceptions import Bolt11Bech32InvalidException
|
||||||
|
import base64, json, time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from lnbits.core.crud.payments import get_daily_stats
|
from lnbits.core.crud.payments import get_daily_stats
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||||
@@ -52,6 +56,62 @@ from .notifications import send_payment_notification
|
|||||||
payment_lock = asyncio.Lock()
|
payment_lock = asyncio.Lock()
|
||||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
def _parse_ark_ticket(ticket: str) -> SimpleNamespace:
|
||||||
|
"""
|
||||||
|
Accepts 'ark1' + base64url(JSON) and returns an object that mimics the
|
||||||
|
attributes LNbits reads from a decoded BOLT11 invoice.
|
||||||
|
Required JSON fields we honor:
|
||||||
|
- amt_sat: int
|
||||||
|
- hash: str (payment hash / checking_id)
|
||||||
|
- ts: int (unix timestamp when created)
|
||||||
|
- exp: int (seconds until expiry)
|
||||||
|
- memo: str (optional)
|
||||||
|
"""
|
||||||
|
if not isinstance(ticket, str) or not ticket.startswith("ark1"):
|
||||||
|
raise ValueError("Not an ARK ticket")
|
||||||
|
|
||||||
|
b64u = ticket[4:]
|
||||||
|
b64u += "=" * (-len(b64u) % 4) # restore padding
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(b64u).decode())
|
||||||
|
|
||||||
|
amt_sat = int(payload.get("amt_sat", 0))
|
||||||
|
ts = int(payload.get("ts", int(time.time())))
|
||||||
|
exp = int(payload.get("exp", 3600))
|
||||||
|
memo = payload.get("memo") or ""
|
||||||
|
p_hash = payload.get("hash") or payload.get("payment_hash")
|
||||||
|
if not isinstance(p_hash, str):
|
||||||
|
raise ValueError("ARK ticket missing 'hash'")
|
||||||
|
|
||||||
|
# Match attributes accessed later in this module:
|
||||||
|
# - amount_msat (int)
|
||||||
|
# - payment_hash (str)
|
||||||
|
# - description (str)
|
||||||
|
# - expiry_date (int unix ts expected by CreatePayment)
|
||||||
|
# - tags.get(...) is sometimes called; return a harmless stub
|
||||||
|
return SimpleNamespace(
|
||||||
|
amount_msat=amt_sat * 1000,
|
||||||
|
payment_hash=p_hash,
|
||||||
|
description=memo,
|
||||||
|
expiry_date=ts + exp, # unix timestamp
|
||||||
|
timestamp=ts, # not strictly required here but nice to have
|
||||||
|
tags=SimpleNamespace(get=lambda *a, **k: None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep a reference to the real decoder, then override the name used below
|
||||||
|
_real_bolt11_decode = bolt11_decode
|
||||||
|
|
||||||
|
def _safe_decode_payment_request(pr: str):
|
||||||
|
try:
|
||||||
|
return _real_bolt11_decode(pr)
|
||||||
|
except Bolt11Bech32InvalidException:
|
||||||
|
# Fallback: accept ARK tickets
|
||||||
|
if isinstance(pr, str) and pr.startswith("ark1"):
|
||||||
|
return _parse_ark_ticket(pr)
|
||||||
|
# Not ARK? re-raise to keep existing behavior
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Override the imported name used throughout this file:
|
||||||
|
bolt11_decode = _safe_decode_payment_request
|
||||||
|
|
||||||
async def pay_invoice(
|
async def pay_invoice(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -84,27 +84,6 @@
|
|||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>
|
|
||||||
<span v-text="$t('misc_disable_extensions_builder')"></span>
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<span
|
|
||||||
v-text="$t('misc_disable_extensions_builder_label')"
|
|
||||||
></span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
size="md"
|
|
||||||
v-model="formData.lnbits_extensions_builder_activate_non_admins"
|
|
||||||
checked-icon="check"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
@@ -126,17 +105,6 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<p>
|
|
||||||
<span v-text="$t('extension_builder_manifest_url')"></span>
|
|
||||||
</p>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_extensions_builder_manifest_url"
|
|
||||||
:label="$t('extension_builder_manifest_url')"
|
|
||||||
:hint="$t('extension_builder_manifest_url_hint')"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
v-text="$t('only_admins_can_install')"
|
v-text="$t('only_admins_can_install')"
|
||||||
></i>
|
></i>
|
||||||
<q-space></q-space>
|
<q-space></q-space>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
:label="$t('search_extensions')"
|
:label="$t('search_extensions')"
|
||||||
:dense="dense"
|
:dense="dense"
|
||||||
@@ -54,18 +53,6 @@
|
|||||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||||
></span>
|
></span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
{% if extension_builder_enabled %}
|
|
||||||
<q-btn flat no-caps icon="architecture" to="/extensions/builder"
|
|
||||||
><span v-text="$t('create_extension')"></span
|
|
||||||
></q-btn>
|
|
||||||
{% else %}
|
|
||||||
<q-btn disabled flat no-caps icon="architecture"
|
|
||||||
><span v-text="$t('create_extension')"></span>
|
|
||||||
<q-tooltip
|
|
||||||
v-text="$t('only_admins_can_create_extensions')"
|
|
||||||
></q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
{% endif %}
|
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="g.user.admin"
|
v-if="g.user.admin"
|
||||||
flat
|
flat
|
||||||
@@ -137,40 +124,8 @@
|
|||||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||||
v-text="extension.name"
|
v-text="extension.name"
|
||||||
></div>
|
></div>
|
||||||
<div style="justify-content: space-between; display: flex">
|
<div>
|
||||||
<lnbits-extension-rating :rating="0" />
|
<lnbits-extension-rating :rating="0" />
|
||||||
<q-btn-group size="xs" style="margin: 5px 0">
|
|
||||||
<q-btn
|
|
||||||
v-if="extension.hasFreeRelease"
|
|
||||||
color="green"
|
|
||||||
size="xs"
|
|
||||||
:label="$t('free')"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('extension_has_free_release')"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
v-if="extension.hasPaidRelease || extension.paidFeatures"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
:label="$t('paid')"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span
|
|
||||||
v-if="extension.hasPaidRelease"
|
|
||||||
v-text="$t('extension_has_paid_release')"
|
|
||||||
></span>
|
|
||||||
<br
|
|
||||||
v-if="extension.hasPaidRelease && extension.paidFeatures"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="extension.paidFeatures"
|
|
||||||
v-text="extension.paidFeatures"
|
|
||||||
></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-btn-group>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="justify-content: space-between; display: flex">
|
<div style="justify-content: space-between; display: flex">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
@@ -948,6 +903,7 @@
|
|||||||
:href="selectedExtensionDetails.repo"
|
:href="selectedExtensionDetails.repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
class="q-pr-xs"
|
||||||
><q-tooltip>repository</q-tooltip></q-btn
|
><q-tooltip>repository</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,830 +0,0 @@
|
|||||||
{% 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,7 +3,11 @@ import traceback
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extensions
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
@@ -61,10 +65,9 @@ async def api_install_extension(data: CreateExtension):
|
|||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
if not release:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||||
if not release.is_version_compatible:
|
)
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
|
||||||
|
|
||||||
release.payment_hash = data.payment_hash
|
release.payment_hash = data.payment_hash
|
||||||
ext_meta = ExtensionMeta(installed_release=release)
|
ext_meta = ExtensionMeta(installed_release=release)
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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,6 +1,4 @@
|
|||||||
from hashlib import sha256
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
@@ -126,9 +124,6 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
if ext.meta and ext.meta.latest_release
|
if ext.meta and ext.meta.latest_release
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
|
||||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
|
||||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
|
||||||
"installedRelease": (
|
"installedRelease": (
|
||||||
dict(ext.meta.installed_release)
|
dict(ext.meta.installed_release)
|
||||||
if ext.meta and ext.meta.installed_release
|
if ext.meta and ext.meta.installed_release
|
||||||
@@ -154,95 +149,11 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
"extension_data": extension_data,
|
"extension_data": extension_data,
|
||||||
"extension_builder_enabled": user.admin
|
|
||||||
or settings.lnbits_extensions_builder_activate_non_admins,
|
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
|
||||||
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
|
|
||||||
)
|
|
||||||
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
"core/extensions_builder.html",
|
|
||||||
{
|
|
||||||
"user": user.json(),
|
|
||||||
"ajax": _is_ajax_request(request),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
|
||||||
"/extensions/builder/preview/{ext_id}",
|
|
||||||
name="extensions builder",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
)
|
|
||||||
async def extensions_builder_preview(
|
|
||||||
request: Request,
|
|
||||||
ext_id: str,
|
|
||||||
page_name: str | None = None,
|
|
||||||
user: User = Depends(check_user_exists),
|
|
||||||
):
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
|
||||||
html_file_name = "index.html"
|
|
||||||
if page_name == "public_page":
|
|
||||||
html_file_name = "public_page.html"
|
|
||||||
|
|
||||||
html_file_path = Path(
|
|
||||||
"extension_builder_stub",
|
|
||||||
ext_id,
|
|
||||||
working_dir_name,
|
|
||||||
ext_id,
|
|
||||||
"templates",
|
|
||||||
ext_id,
|
|
||||||
html_file_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
html_file_full_path = Path(
|
|
||||||
settings.extension_builder_working_dir_path, html_file_path
|
|
||||||
)
|
|
||||||
|
|
||||||
if not html_file_full_path.is_file():
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
"error.html",
|
|
||||||
{
|
|
||||||
"err": f"Extension {ext_id} not found",
|
|
||||||
"message": "Please 'Refresh Preview' first.",
|
|
||||||
},
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
html_file_path.as_posix(),
|
|
||||||
{
|
|
||||||
"user": user.json(),
|
|
||||||
"ajax": _is_ajax_request(request),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response.headers["Content-Security-Policy"] = (
|
|
||||||
"default-src 'self'; "
|
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
|
|||||||
@@ -296,9 +296,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
|||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
|
|
||||||
if payment.failed:
|
if payment.failed:
|
||||||
if wallet and wallet.id == payment.wallet_id:
|
return {"paid": False, "status": "failed", "details": payment}
|
||||||
return {"paid": False, "status": "failed", "details": payment}
|
|
||||||
return {"paid": False, "status": "failed"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
|
|||||||
+1
-30
@@ -53,12 +53,7 @@ def static_url_for(static: str, path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
||||||
folders = [
|
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||||
"lnbits/templates",
|
|
||||||
"lnbits/core/templates",
|
|
||||||
settings.extension_builder_working_dir_path.as_posix(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
additional_folders += [
|
additional_folders += [
|
||||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||||
@@ -373,27 +368,3 @@ def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
|||||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||||
)
|
)
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
def camel_to_words(name: str) -> str:
|
|
||||||
# Add space before capital letters (but not at the start)
|
|
||||||
words = re.sub(r"(?<!^)(?=[A-Z])", " ", name)
|
|
||||||
return words.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def camel_to_snake(name: str) -> str:
|
|
||||||
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
||||||
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
||||||
return name.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def is_camel_case(v: str) -> bool:
|
|
||||||
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def is_snake_case(v: str) -> bool:
|
|
||||||
return re.match(r"^[a-z]+(_[a-z0-9]+)*$", v) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def lowercase_first_letter(s: str) -> str:
|
|
||||||
return s[:1].lower() + s[1:] if s else s
|
|
||||||
|
|||||||
+1
-8
@@ -51,19 +51,11 @@ class ExtensionsSettings(LNbitsSettings):
|
|||||||
lnbits_admin_extensions: list[str] = Field(default=[])
|
lnbits_admin_extensions: list[str] = Field(default=[])
|
||||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||||
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
|
||||||
lnbits_extensions_manifests: list[str] = Field(
|
lnbits_extensions_manifests: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
lnbits_extensions_builder_manifest_url: str = Field(
|
|
||||||
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extension_builder_working_dir_path(self) -> Path:
|
|
||||||
return Path(settings.lnbits_data_folder, "extensions_builder")
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionsInstallSettings(LNbitsSettings):
|
class ExtensionsInstallSettings(LNbitsSettings):
|
||||||
@@ -982,6 +974,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
"NWCWallet",
|
"NWCWallet",
|
||||||
"StrikeWallet",
|
"StrikeWallet",
|
||||||
|
"ArkFakeWallet",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+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
+1
-1
File diff suppressed because one or more lines are too long
@@ -243,13 +243,6 @@ body.body--dark .q-field--error .q-field__messages {
|
|||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blur-and-disable {
|
|
||||||
filter: blur(4px); /* blur entire element & children */
|
|
||||||
pointer-events: none; /* block mouse interaction */
|
|
||||||
user-select: none; /* prevent text selection */
|
|
||||||
opacity: 0.6; /* optional: faded look */
|
|
||||||
}
|
|
||||||
|
|
||||||
.lnbits__table-bordered td,
|
.lnbits__table-bordered td,
|
||||||
.lnbits__table-bordered th {
|
.lnbits__table-bordered th {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
|
|||||||
@@ -164,12 +164,8 @@ window.localisation.en = {
|
|||||||
featured: 'Featured',
|
featured: 'Featured',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||||
only_admins_can_create_extensions:
|
|
||||||
'Only admin accounts can create extensions',
|
|
||||||
admin_only: 'Admin Only',
|
admin_only: 'Admin Only',
|
||||||
new_version: 'New Version',
|
new_version: 'New Version',
|
||||||
extension_has_free_release: 'Has free releases',
|
|
||||||
extension_has_paid_release: 'Has paid releases',
|
|
||||||
extension_depends_on: 'Depends on:',
|
extension_depends_on: 'Depends on:',
|
||||||
extension_rating_soon: 'Ratings coming soon',
|
extension_rating_soon: 'Ratings coming soon',
|
||||||
extension_installed_version: 'Installed version',
|
extension_installed_version: 'Installed version',
|
||||||
@@ -424,7 +420,6 @@ window.localisation.en = {
|
|||||||
admin_settings: 'Admin Settings',
|
admin_settings: 'Admin Settings',
|
||||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||||
create_extension: 'Create Extension',
|
|
||||||
release_details_error: 'Cannot get the release details.',
|
release_details_error: 'Cannot get the release details.',
|
||||||
pay_from_wallet: 'Pay from Wallet',
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
pay_with: 'Pay with {provider}',
|
pay_with: 'Pay with {provider}',
|
||||||
@@ -494,16 +489,9 @@ window.localisation.en = {
|
|||||||
user_default_extensions_label: 'User extensions',
|
user_default_extensions_label: 'User extensions',
|
||||||
user_default_extensions_hint:
|
user_default_extensions_hint:
|
||||||
'Extensions that will be enabled by default for the users.',
|
'Extensions that will be enabled by default for the users.',
|
||||||
extension_builder: 'Extension Builder',
|
|
||||||
extension_builder_manifest_url: 'Extension Builder Manifest URL',
|
|
||||||
extension_builder_manifest_url_hint:
|
|
||||||
'URL to a JSON manifest file with extension builder details',
|
|
||||||
miscellanous: 'Miscellanous',
|
miscellanous: 'Miscellanous',
|
||||||
misc_disable_extensions: 'Disable Extensions',
|
misc_disable_extensions: 'Disable Extensions',
|
||||||
misc_disable_extensions_label: 'Disable all extensions',
|
misc_disable_extensions_label: 'Disable all extensions',
|
||||||
misc_disable_extensions_builder: 'Enable Extensions Builder',
|
|
||||||
misc_disable_extensions_builder_label:
|
|
||||||
'Enable Extensions Builder for non admin users.',
|
|
||||||
misc_hide_api: 'Hide API',
|
misc_hide_api: 'Hide API',
|
||||||
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
|
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
|
||||||
wallets_management: 'Wallets Management',
|
wallets_management: 'Wallets Management',
|
||||||
@@ -665,7 +653,5 @@ window.localisation.en = {
|
|||||||
callback_success_url_hint:
|
callback_success_url_hint:
|
||||||
'The user will be redirected to this URL after the payment is successful',
|
'The user will be redirected to this URL after the payment is successful',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
not_connected: 'Not Connected',
|
not_connected: 'Not Connected'
|
||||||
free: 'Free',
|
|
||||||
paid: 'Paid'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,10 +468,7 @@ window.AdminPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
return moment
|
return moment.utc(date * 1000).fromNow()
|
||||||
.utc(date * 1000)
|
|
||||||
.local()
|
|
||||||
.fromNow()
|
|
||||||
},
|
},
|
||||||
sendTestEmail() {
|
sendTestEmail() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
window.LNbits = {
|
window.LNbits = {
|
||||||
g: window.g,
|
g: window.g,
|
||||||
api: {
|
api: {
|
||||||
request(method, url, apiKey, data, options = {}) {
|
request(method, url, apiKey, data) {
|
||||||
return axios({
|
return axios({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': apiKey
|
'X-Api-Key': apiKey
|
||||||
},
|
},
|
||||||
data: data,
|
data: data
|
||||||
...options
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getServerHealth() {
|
getServerHealth() {
|
||||||
@@ -267,10 +266,10 @@ window.LNbits = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||||
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
|
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
||||||
|
|
||||||
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||||
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
|
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra?.tag
|
obj.tag = obj.extra?.tag
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
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,15 +184,6 @@ const routes = [
|
|||||||
scripts: ['/static/js/extensions.js']
|
scripts: ['/static/js/extensions.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/extensions/builder',
|
|
||||||
name: 'ExtensionsBuilder',
|
|
||||||
component: DynamicComponent,
|
|
||||||
props: {
|
|
||||||
fetchUrl: '/extensions/builder',
|
|
||||||
scripts: ['/static/js/extensions_builder.js']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/account',
|
path: '/account',
|
||||||
name: 'Account',
|
name: 'Account',
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
|||||||
return LNbits.utils.formatDate(this.ts)
|
return LNbits.utils.formatDate(this.ts)
|
||||||
},
|
},
|
||||||
dateFrom() {
|
dateFrom() {
|
||||||
return moment.utc(this.date).local().fromNow()
|
return moment.utc(this.date).fromNow()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
|
|||||||
if (p.extra && p.extra.tag) {
|
if (p.extra && p.extra.tag) {
|
||||||
p.tag = p.extra.tag
|
p.tag = p.extra.tag
|
||||||
}
|
}
|
||||||
p.timeFrom = moment.utc(p.created_at).local().fromNow()
|
p.timeFrom = moment.utc(p.created_at).fromNow()
|
||||||
p.outgoing = p.amount < 0
|
p.outgoing = p.amount < 0
|
||||||
p.amount =
|
p.amount =
|
||||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
|
|||||||
methods: {
|
methods: {
|
||||||
dateFromNow(unix) {
|
dateFromNow(unix) {
|
||||||
const date = new Date(unix * 1000)
|
const date = new Date(unix * 1000)
|
||||||
return moment.utc(date).local().fromNow()
|
return moment.utc(date).fromNow()
|
||||||
},
|
},
|
||||||
formatFiatAmount(amount, currency) {
|
formatFiatAmount(amount, currency) {
|
||||||
this.update.currency = currency
|
this.update.currency = currency
|
||||||
@@ -476,14 +476,8 @@ window.WalletPageLogic = {
|
|||||||
createdDate,
|
createdDate,
|
||||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||||
)
|
)
|
||||||
cleanInvoice.expireDateFrom = moment
|
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
||||||
.utc(expireDate)
|
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
||||||
.local()
|
|
||||||
.fromNow()
|
|
||||||
cleanInvoice.createdDateFrom = moment
|
|
||||||
.utc(createdDate)
|
|
||||||
.local()
|
|
||||||
.fromNow()
|
|
||||||
|
|
||||||
cleanInvoice.expired = false // TODO
|
cleanInvoice.expired = false // TODO
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ body.body--dark .q-field--error {
|
|||||||
.lnbits__dialog-card {
|
.lnbits__dialog-card {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
.blur-and-disable {
|
|
||||||
filter: blur(4px); /* blur entire element & children */
|
|
||||||
pointer-events: none; /* block mouse interaction */
|
|
||||||
user-select: none; /* prevent text selection */
|
|
||||||
opacity: 0.6; /* optional: faded look */
|
|
||||||
}
|
|
||||||
|
|
||||||
.lnbits__table-bordered td,
|
.lnbits__table-bordered td,
|
||||||
.lnbits__table-bordered th {
|
.lnbits__table-bordered th {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
"js/components/lnbits-qrcode-lnurl.js",
|
"js/components/lnbits-qrcode-lnurl.js",
|
||||||
"js/components/lnbits-funding-sources.js",
|
"js/components/lnbits-funding-sources.js",
|
||||||
"js/components/extension-settings.js",
|
"js/components/extension-settings.js",
|
||||||
"js/components/data-fields.js",
|
|
||||||
"js/components/payment-list.js",
|
"js/components/payment-list.js",
|
||||||
"js/components.js",
|
"js/components.js",
|
||||||
"js/init-app.js"
|
"js/init-app.js"
|
||||||
|
|||||||
@@ -649,7 +649,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showButtons"
|
v-if="showButtons"
|
||||||
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
|
class="qrcode__buttons row q-gutter-x-sm"
|
||||||
|
style="justify-content: flex-end"
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="nfc && nfcSupported"
|
v-if="nfc && nfcSupported"
|
||||||
@@ -1670,189 +1671,3 @@
|
|||||||
<q-separator class="col q-ml-sm"></q-separator>
|
<q-separator class="col q-ml-sm"></q-separator>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="lnbits-data-fields">
|
|
||||||
<q-table
|
|
||||||
:rows="fields"
|
|
||||||
row-key="name"
|
|
||||||
:columns="fieldsTable.columns"
|
|
||||||
v-model:pagination="fieldsTable.pagination"
|
|
||||||
>
|
|
||||||
<template v-slot:bottom-row>
|
|
||||||
<q-tr>
|
|
||||||
<q-td auto-width></q-td>
|
|
||||||
<q-td colspan="100%">
|
|
||||||
<q-btn
|
|
||||||
@click="addField"
|
|
||||||
icon="add"
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
class="q-ml-xs"
|
|
||||||
:label="$t('add_field')"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:header="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.label"></span>
|
|
||||||
<q-icon
|
|
||||||
v-if="col.name == 'optional'"
|
|
||||||
name="info"
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The field is optional. The field can be left blank by the
|
|
||||||
user.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The UI form will not require this field to be filled out.
|
|
||||||
</li>
|
|
||||||
<li>The DB table will allow NULL values for this field.</li>
|
|
||||||
<li>Non optional fields must be filled out.</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon
|
|
||||||
v-else-if="col.name == 'editable'"
|
|
||||||
name="info"
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul>
|
|
||||||
<li>The UI form will allow the field to be edited.</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon
|
|
||||||
v-else-if="col.name == 'sortable'"
|
|
||||||
name="info"
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul>
|
|
||||||
<li>In the UI Table a column will be created for the field.</li>
|
|
||||||
<li>The UI Table column will be sortable.</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon
|
|
||||||
v-else-if="col.name == 'searchable'"
|
|
||||||
name="info"
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The free text search will include this field when searching.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-th>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td>
|
|
||||||
<q-btn
|
|
||||||
v-if="props.row.readonly !== true"
|
|
||||||
@click="removeField(props.row)"
|
|
||||||
round
|
|
||||||
icon="delete"
|
|
||||||
size="sm"
|
|
||||||
color="negative"
|
|
||||||
class="q-ml-xs"
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
</q-td>
|
|
||||||
<q-td full-width>
|
|
||||||
<q-input
|
|
||||||
dense
|
|
||||||
filled
|
|
||||||
v-model="props.row.name"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="props.row.type"
|
|
||||||
:options="fieldTypes"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
></q-select>
|
|
||||||
</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-input dense filled v-model="props.row.label" type="text">
|
|
||||||
</q-input>
|
|
||||||
</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-input dense filled v-model="props.row.hint" type="text"> </q-input>
|
|
||||||
</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-toggle
|
|
||||||
v-model="props.row.optional"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td v-if="!hideAdvanced">
|
|
||||||
<q-toggle
|
|
||||||
v-if="props.row.type !== 'json'"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
v-model="props.row.editable"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td v-if="!hideAdvanced">
|
|
||||||
<q-toggle
|
|
||||||
v-if="props.row.type !== 'json'"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
v-model="props.row.sortable"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td v-if="!hideAdvanced">
|
|
||||||
<q-toggle
|
|
||||||
v-if="props.row.type !== 'json'"
|
|
||||||
:readonly="props.row.readonly === true"
|
|
||||||
v-model="props.row.searchable"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr v-if="props.row.type === 'json'" :props="props">
|
|
||||||
<q-td></q-td>
|
|
||||||
<q-td></q-td>
|
|
||||||
<q-td colspan="100%">
|
|
||||||
<lnbits-data-fields
|
|
||||||
:fields="props.row.fields"
|
|
||||||
:hide-advanced="true"
|
|
||||||
></lnbits-data-fields>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</template>
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from .spark import SparkWallet
|
|||||||
from .strike import StrikeWallet
|
from .strike import StrikeWallet
|
||||||
from .void import VoidWallet
|
from .void import VoidWallet
|
||||||
from .zbd import ZBDWallet
|
from .zbd import ZBDWallet
|
||||||
|
from .fakeark import ArkFakeWallet
|
||||||
|
|
||||||
|
|
||||||
def set_funding_source(class_name: str | None = None) -> None:
|
def set_funding_source(class_name: str | None = None) -> None:
|
||||||
@@ -79,4 +80,5 @@ __all__ = [
|
|||||||
"StrikeWallet",
|
"StrikeWallet",
|
||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
|
"ArkFakeWallet",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# lnbits/wallets/ark_fake.py
|
||||||
|
import asyncio, json, base64
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from os import urandom
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.crypto import fake_privkey # unused but kept for parity/logs
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
InvoiceResponse,
|
||||||
|
PaymentFailedStatus,
|
||||||
|
PaymentPendingStatus,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
PaymentSuccessStatus,
|
||||||
|
StatusResponse,
|
||||||
|
Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ark_encode(payload: dict[str, Any]) -> str:
|
||||||
|
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
|
return "ark1" + base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
class ArkFakeWallet(Wallet):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||||
|
self.payment_secrets: dict[str, str] = {}
|
||||||
|
self.paid_invoices: set[str] = set()
|
||||||
|
self.secret = settings.fake_wallet_secret
|
||||||
|
self.privkey = fake_privkey(self.secret)
|
||||||
|
|
||||||
|
async def cleanup(self): pass
|
||||||
|
|
||||||
|
async def status(self) -> StatusResponse:
|
||||||
|
logger.info("ArkFakeWallet: ARK-only test funding source (no bolt11).")
|
||||||
|
return StatusResponse(None, 1_000_000_000)
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self, amount: int, memo: str | None = None,
|
||||||
|
description_hash: bytes | None = None,
|
||||||
|
unhashed_description: bytes | None = None,
|
||||||
|
expiry: int | None = None,
|
||||||
|
payment_secret: bytes | None = None,
|
||||||
|
**_,
|
||||||
|
) -> InvoiceResponse:
|
||||||
|
preimage = urandom(32)
|
||||||
|
p_hash = sha256(preimage).hexdigest()
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
ticket = ark_encode({
|
||||||
|
"v": 1,
|
||||||
|
"ts": now,
|
||||||
|
"amt_sat": int(amount),
|
||||||
|
"memo": memo or "",
|
||||||
|
"hash": p_hash,
|
||||||
|
"exp": int(expiry or 3600),
|
||||||
|
"secret": (payment_secret.hex() if payment_secret else urandom(32).hex()),
|
||||||
|
})
|
||||||
|
self.payment_secrets[p_hash] = preimage.hex()
|
||||||
|
return InvoiceResponse(
|
||||||
|
ok=True,
|
||||||
|
checking_id=p_hash,
|
||||||
|
payment_request=ticket, # <-- non-bolt11, starts with ark1
|
||||||
|
preimage=preimage.hex(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
||||||
|
# Only allow paying our own tickets (like FakeWallet behavior)
|
||||||
|
# We don’t parse here because we only care about internal payments.
|
||||||
|
# If you want to parse/validate, you can mirror the core’s ark parser.
|
||||||
|
for p_hash in list(self.payment_secrets.keys()):
|
||||||
|
if p_hash in bolt11: # cheap check; or properly decode ark1 and compare its "hash"
|
||||||
|
await self.queue.put(p_hash)
|
||||||
|
self.paid_invoices.add(p_hash)
|
||||||
|
return PaymentResponse(
|
||||||
|
ok=True,
|
||||||
|
checking_id=p_hash,
|
||||||
|
fee_msat=0,
|
||||||
|
preimage=self.payment_secrets[p_hash],
|
||||||
|
)
|
||||||
|
return PaymentResponse(ok=False, error_message="Only internal ARK tickets can be used!")
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
if checking_id in self.paid_invoices:
|
||||||
|
return PaymentSuccessStatus()
|
||||||
|
if checking_id in self.payment_secrets:
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
async def get_payment_status(self, _: str) -> PaymentStatus:
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
while settings.lnbits_running:
|
||||||
|
checking_id = await self.queue.get()
|
||||||
|
yield checking_id
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
# LNBits NixOS Installation Guide
|
|
||||||
|
|
||||||
This guide shows how to install LNBits on a fresh NixOS system.
|
|
||||||
|
|
||||||
## Quick Start (Recommended)
|
|
||||||
|
|
||||||
Add this to your NixOS configuration (`/etc/nixos/configuration.nix`):
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
lnbitsFlake = builtins.getFlake "github:lnbits/lnbits";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
# Import LNBits service module directly from GitHub
|
|
||||||
"${lnbitsFlake}/nix/modules/lnbits-service.nix"
|
|
||||||
];
|
|
||||||
|
|
||||||
# Enable flakes (required)
|
|
||||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
|
||||||
|
|
||||||
# Configure LNBits service
|
|
||||||
services.lnbits = {
|
|
||||||
enable = true;
|
|
||||||
host = "0.0.0.0"; # Listen on all interfaces
|
|
||||||
port = 5000; # Default port
|
|
||||||
openFirewall = true; # Open firewall port automatically
|
|
||||||
|
|
||||||
# Use package from the same flake (adjust system architecture as needed)
|
|
||||||
package = lnbitsFlake.packages.x86_64-linux.lnbits;
|
|
||||||
|
|
||||||
env = {
|
|
||||||
LNBITS_ADMIN_UI = "true";
|
|
||||||
# Configure your Lightning backend:
|
|
||||||
# LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
|
|
||||||
# LND_REST_ENDPOINT = "https://localhost:8080";
|
|
||||||
# LND_REST_CERT = "/path/to/tls.cert";
|
|
||||||
# LND_REST_MACAROON = "/path/to/admin.macaroon";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Rebuild and switch
|
|
||||||
# sudo nixos-rebuild switch
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **⚠️ System Architecture Note**: The examples above use `x86_64-linux`. Replace this with your system architecture:
|
|
||||||
>
|
|
||||||
> - `x86_64-linux` - Intel/AMD 64-bit Linux
|
|
||||||
> - `aarch64-linux` - ARM 64-bit Linux (e.g., Raspberry Pi 4, Apple Silicon under Linux)
|
|
||||||
> - `x86_64-darwin` - Intel Mac
|
|
||||||
> - `aarch64-darwin` - Apple Silicon Mac
|
|
||||||
>
|
|
||||||
> You can check your system with: `nix eval --impure --raw --expr 'builtins.currentSystem'`
|
|
||||||
|
|
||||||
## Alternative: Using Your Own Flake
|
|
||||||
|
|
||||||
Create a `flake.nix` for your system configuration:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
description = "My NixOS configuration with LNBits";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
|
||||||
lnbits.url = "github:lnbits/lnbits";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, lnbits }: {
|
|
||||||
nixosConfigurations.myserver = nixpkgs.lib.nixosSystem {
|
|
||||||
system = "x86_64-linux"; # Adjust architecture as needed
|
|
||||||
modules = [
|
|
||||||
./hardware-configuration.nix
|
|
||||||
{
|
|
||||||
services.lnbits = {
|
|
||||||
enable = true;
|
|
||||||
host = "0.0.0.0";
|
|
||||||
port = 5000;
|
|
||||||
openFirewall = true;
|
|
||||||
package = lnbits.packages.x86_64-linux.lnbits; # Adjust architecture as needed
|
|
||||||
env = {
|
|
||||||
LNBITS_ADMIN_UI = "true";
|
|
||||||
# Add your Lightning backend configuration
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then deploy with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nixos-rebuild switch --flake .#myserver
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
### Basic Options
|
|
||||||
|
|
||||||
- `enable`: Enable the LNBits service (default: `false`)
|
|
||||||
- `host`: Host to bind to (default: `"127.0.0.1"`)
|
|
||||||
- `port`: Port to run on (default: `8231`)
|
|
||||||
- `openFirewall`: Automatically open firewall port (default: `false`)
|
|
||||||
- `user`: User to run as (default: `"lnbits"`)
|
|
||||||
- `group`: Group to run as (default: `"lnbits"`)
|
|
||||||
- `stateDir`: State directory (default: `"/var/lib/lnbits"`)
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Configure LNBits through the `env` option. Common variables:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
services.lnbits.env = {
|
|
||||||
# Admin UI
|
|
||||||
LNBITS_ADMIN_UI = "true";
|
|
||||||
|
|
||||||
# LND Backend Example:
|
|
||||||
|
|
||||||
# LND
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
|
|
||||||
LND_REST_ENDPOINT = "https://localhost:8080";
|
|
||||||
LND_REST_CERT = "/path/to/tls.cert";
|
|
||||||
LND_REST_MACAROON = "/path/to/admin.macaroon";
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [LNBits documentation](https://docs.lnbits.org/guide/wallets.html) for all supported backends.
|
|
||||||
|
|
||||||
## State Directory Structure
|
|
||||||
|
|
||||||
LNBits data is stored in `/var/lib/lnbits` (default) with this structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
/var/lib/lnbits/
|
|
||||||
├── data/ # Application data
|
|
||||||
│ ├── database.sqlite3 # Main database
|
|
||||||
│ ├── ext_<extension>.sqlite3 # Extension database
|
|
||||||
│ ├── images/ # Uploaded images
|
|
||||||
│ ├── logs/ # Log files
|
|
||||||
│ └── upgrades/ # Migration files
|
|
||||||
└── extensions/ # Installed extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
## First Time Setup
|
|
||||||
|
|
||||||
1. **Deploy the configuration:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nixos-rebuild switch
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check service status:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status lnbits
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Access the web interface:**
|
|
||||||
|
|
||||||
```
|
|
||||||
http://your-server-ip:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Follow the first-time setup wizard** to configure your Lightning backend and create your first wallet.
|
|
||||||
|
|
||||||
5. **Bonus** Add Reverse Proxy with generated SSL Cert
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
# Enable nginx
|
|
||||||
services.nginx = {
|
|
||||||
enable = true;
|
|
||||||
|
|
||||||
virtualHosts."lnbits.mydomain.com" = {
|
|
||||||
forceSSL = true;
|
|
||||||
enableACME = true;
|
|
||||||
locations."/" = {
|
|
||||||
proxyPass = "http://127.0.0.1:5000";
|
|
||||||
proxyWebsockets = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Service won't start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check service logs
|
|
||||||
journalctl -u lnbits -f
|
|
||||||
|
|
||||||
# Check if port is available
|
|
||||||
ss -tlnp | grep 5000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Can't access web interface
|
|
||||||
|
|
||||||
- Ensure `openFirewall = true` is set
|
|
||||||
- Check if the port is correct: `services.lnbits.port`
|
|
||||||
- Verify host binding: `services.lnbits.host = "0.0.0.0"`
|
|
||||||
|
|
||||||
## Further Reading
|
|
||||||
|
|
||||||
- [LNBits Documentation](https://docs.lnbits.org)
|
|
||||||
- [Lightning Wallet Configuration](https://docs.lnbits.org/guide/wallets.html)
|
|
||||||
- [LNBits Extensions](https://docs.lnbits.org/devs/extensions.html)
|
|
||||||
@@ -35,7 +35,7 @@ in
|
|||||||
type = types.path;
|
type = types.path;
|
||||||
default = "/var/lib/lnbits";
|
default = "/var/lib/lnbits";
|
||||||
description = ''
|
description = ''
|
||||||
The lnbits state directory
|
The lnbits state directory which LNBITS_DATA_FOLDER will be set to
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
host = mkOption {
|
host = mkOption {
|
||||||
@@ -90,7 +90,6 @@ in
|
|||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
|
"d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||||
"d ${cfg.stateDir}/data 0700 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.lnbits = {
|
systemd.services.lnbits = {
|
||||||
@@ -100,18 +99,18 @@ in
|
|||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
environment = lib.mkMerge [
|
environment = lib.mkMerge [
|
||||||
{
|
{
|
||||||
LNBITS_DATA_FOLDER = "${cfg.stateDir}/data";
|
LNBITS_DATA_FOLDER = "${cfg.stateDir}";
|
||||||
# LNBits automatically appends '/extensions' to this path
|
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions";
|
||||||
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}";
|
LNBITS_PATH = "${cfg.package.src}";
|
||||||
}
|
}
|
||||||
cfg.env
|
cfg.env
|
||||||
];
|
];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
Group = cfg.group;
|
Group = cfg.group;
|
||||||
WorkingDirectory = "${cfg.package}/lib/python3.12/site-packages";
|
WorkingDirectory = "${cfg.package.src}";
|
||||||
StateDirectory = "lnbits";
|
StateDirectory = "${cfg.stateDir}";
|
||||||
ExecStart = "${cfg.package}/bin/lnbits --port ${toString cfg.port} --host ${cfg.host}";
|
ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}";
|
||||||
Restart = "always";
|
Restart = "always";
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,7 +94,6 @@
|
|||||||
"js/components/lnbits-qrcode-lnurl.js",
|
"js/components/lnbits-qrcode-lnurl.js",
|
||||||
"js/components/lnbits-funding-sources.js",
|
"js/components/lnbits-funding-sources.js",
|
||||||
"js/components/extension-settings.js",
|
"js/components/extension-settings.js",
|
||||||
"js/components/data-fields.js",
|
|
||||||
"js/components/payment-list.js",
|
"js/components/payment-list.js",
|
||||||
"js/components.js",
|
"js/components.js",
|
||||||
"js/init-app.js"
|
"js/init-app.js"
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ classmethod-decorators = [
|
|||||||
# TODO: remove S101 ignore
|
# TODO: remove S101 ignore
|
||||||
"lnbits/*" = ["S101"]
|
"lnbits/*" = ["S101"]
|
||||||
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
|
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
|
||||||
"lnbits/core/services/extensions_builder.py" = ["S701"]
|
|
||||||
"crypto.py" = ["S324"]
|
"crypto.py" = ["S324"]
|
||||||
"test*.py" = ["S101", "S105", "S106", "S307"]
|
"test*.py" = ["S101", "S105", "S106", "S307"]
|
||||||
"tools*.py" = ["S101", "S608"]
|
"tools*.py" = ["S101", "S608"]
|
||||||
|
|||||||
Reference in New Issue
Block a user