Compare commits

..
Author SHA1 Message Date
Tiago Vasconcelos 55484b9cb4 feat: update payment extra 2025-07-09 22:08:34 +01:00
dni ⚡andGitHub 4db7d20e60 chore: get rid of mypy notes and typecheck more functions (#3257) 2025-07-09 12:08:48 +03:00
02fec90a29 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-09 11:28:35 +03:00
Tiago VasconcelosandGitHub 4efe1a9fca fix: default theme settings (#3256) 2025-07-08 21:03:11 +02:00
cba8c02d68 feat: Add support for the new CLNRest as a funding source (#2778)
Co-authored-by: Amperstrand <amperstrand@localhost>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-08 21:01:36 +02:00
ArcandGitHub 5c9d375713 chore: tooltip for ad (#3255) 2025-07-08 15:35:40 +03:00
dni ⚡andGitHub 264d1fa07c chore: update version to 1.2.0 (#3253) 2025-07-08 10:15:13 +02:00
Vlad StanandGitHub fc3e2b2b4d chore: upgrade protobuf (#3252) 2025-07-08 10:52:14 +03:00
dni ⚡andGitHub 64edeebaaf fix: update and fixes for BreezWallet (#3246) 2025-07-07 18:58:11 +03:00
dni ⚡andGitHub c2739188d0 CI: fix using all optional dependecies (#3251) 2025-07-07 18:22:43 +03:00
3f4d778c95 fix: update success status (#3244)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-07 18:01:15 +03:00
dni ⚡andGitHub c1788c7219 feat: run_interval in tasks and refactor check_pending_payments (#3245) 2025-07-07 14:52:45 +03:00
07c62285c3 [fix] bandit assert warnings (#3243)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-05 13:16:49 +03:00
Vlad StanandGitHub 841c8ce3a9 [fix] bandit sql warnings (#3242) 2025-07-05 11:12:47 +02:00
Vlad StanandGitHub cc6430221c [fix] Small bandit changes (#3241) 2025-07-04 17:29:16 +03:00
4a34548864 feat: add Breez *Liquid* SDK funding source (#2681)
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-04 14:57:56 +03:00
Vlad StanandGitHub 4a3a3a0425 refactor: extract create_payment_request (#3240) 2025-07-04 12:58:47 +03:00
bf8b33a7b2 fix: phoenixd pending payments (#3075)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-04 12:01:50 +03:00
7e08211887 feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-04 11:59:57 +03:00
Vlad Stan 0bbb0aca2d refactor: use while settings.lnbits_running instead of while True 2025-07-03 12:04:46 +03:00
dni ⚡andGitHub 9b1418b1f4 chore: reduce max complexity lint (#3230) 2025-07-02 14:02:47 +03:00
Tiago VasconcelosandGitHub 5f0a8d78e0 [Fix] Display fee in sats (#3235) 2025-07-02 13:38:35 +03:00
dni ⚡andGitHub 2fe8ae1cb1 chore: update to version v1.2.0-rc3 (#3231) 2025-07-01 13:04:50 +02:00
Vlad StanandGitHub 1a3d23bbee fix: condition for available, but not installed extension (#3232) 2025-07-01 13:33:23 +03:00
Vlad StanandGitHub ae45c1b958 doc: LNBITS_EXTENSIONS_PATH (#3229) 2025-06-30 16:27:44 +03:00
dni ⚡andGitHub 8337351d6a chore: update python packages + formatting + cleanup (#3221) 2025-06-30 15:53:11 +03:00
dni ⚡andGitHub f4dbd369be test: add regtest testcase for no route failure (#3189) 2025-06-30 14:51:34 +02:00
Vlad StanandGitHub 974ee26224 fix: url_for should not return extension upgrades temp paths (#3227) 2025-06-30 15:30:57 +03:00
dni ⚡andGitHub e4fb4453c3 fix: circular import on handle_fiat_payments (#3226) 2025-06-30 14:00:34 +02:00
dni ⚡andGitHub aa7129a351 feat: add internal_invoice_queue_put to get rid of dynamic import (#3223) 2025-06-30 12:55:23 +02:00
Vlad StanandGitHub aba6e4cd87 [feat] Add stripe payments (#3184) 2025-06-30 13:13:13 +03:00
blackcoffeexbtandGitHub f4e7d0c70c Label tweaks (#3224) 2025-06-30 11:14:44 +03:00
Vlad StanandGitHub 4247b9d9a3 feat: add external id for users (#3219) 2025-06-26 15:33:38 +02:00
169 changed files with 9230 additions and 14714 deletions
+59 -88
View File
@@ -1,95 +1,11 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
###### .env ONLY SETTINGS ############
######################################
# The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database.
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# === Admin Settings ===
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
HOST=127.0.0.1
PORT=5000
# VERSION=
# USER_AGENT=
# === LNbits ===
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
FORWARDED_ALLOW_IPS="*"
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# LNBITS_TITLE="LNbits API"
# LNBITS_PATH="folder/path"
# === Auth Configurations ===
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
######################################
###### END .env ONLY SETTINGS ########
######################################
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
@@ -100,6 +16,14 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
########### Admin Settings ###########
######################################
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
@@ -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_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=127.0.0.1
PORT=5000
######################################
########## Funding Source ############
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=3600
@@ -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
LNBITS_RATE_LIMIT_NO="200"
LNBITS_RATE_LIMIT_UNIT="minute"
@@ -273,7 +210,8 @@ LNBITS_BLOCKED_IPS=""
# if set new users will not be able to create accounts
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
@@ -306,6 +244,26 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# the service fee (in percent)
LNBITS_SERVICE_FEE=0.0
# the wallet where fees go to
@@ -334,3 +292,16 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
###### Logging and Development #######
######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
+18 -5
View File
@@ -21,16 +21,29 @@ runs:
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
cache: "pip"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Poetry
uses: abatilo/actions-poetry@v2
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
enable-cache: true
python-version: ${{ inputs.python-version }}
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
shell: bash
run: uv sync --locked --all-extras --dev
run: |
poetry env use python${{ inputs.python-version }}
poetry install --all-extras
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/api"
custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -34,7 +34,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/wallets"
custom-pytest: "poetry run pytest tests/wallets"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -48,7 +48,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/unit"
custom-pytest: "poetry run pytest tests/unit"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -77,7 +77,7 @@ jobs:
python-version: ["3.10"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "uv run pytest tests/regtest"
custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets:
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
uv run lnbits &
poetry run lnbits &
sleep 10
- name: setup java version
-3
View File
@@ -53,6 +53,3 @@ jobs:
with:
make: checkbundle
npm: true
poetry:
uses: ./.github/workflows/poetry.yml
+3 -2
View File
@@ -14,17 +14,18 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'uv.lock'
- 'poetry.lock'
- '.github/workflows/nix.yml'
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'uv.lock'
- 'poetry.lock'
jobs:
nix:
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
+16 -49
View File
@@ -1,4 +1,4 @@
name: Build LNbits AppImage
name: Build LNbits AppImage DMG
on:
release:
@@ -11,89 +11,56 @@ jobs:
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: Set up Python (uv will still use this toolchain)
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Step 3: Install system deps (fuse) + uv
- name: Install system deps and uv
# Step 2: Install Dependencies
- name: Install Dependencies
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
sudo apt-get update
sudo apt-get install -y libfuse2
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash
# Optional: Cache uv + venv to speed up CI
- name: Cache uv and venv
uses: actions/cache@v4
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
# Step 4: Prepare packaging tree and clone LNbits
- name: Prepare packaging & clone LNbits
# Step 3: Clone LNbits Repository
- name: Clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
# Step 5: Build the LNbits binary with uv + PyInstaller
- name: Build LNbits binary (uv + PyInstaller)
# Step 4: Make the AppImage Asset
- name: Make Asset
run: |
cd packaging/linux/AppDir/usr/lnbits
# Install project deps into .venv using uv
uv sync
# Install PyInstaller into the same environment
uv pip install pyinstaller
poetry install
poetry run pip install pyinstaller
# Build the LNbits binary
uv run pyinstaller \
--onefile \
--name lnbits \
--hidden-import=embit \
--collect-all embit \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)"
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
# Clean out non-dist content from the app dir to keep AppImage slim
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
# Build AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ github.event.release.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
packaging/linux/AppDir "$APPIMAGE_NAME"
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
shell: bash
# Step 6: Upload Linux Release Asset
# Step 5: Upload Linux Release Asset
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
@@ -102,4 +69,4 @@ jobs:
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-24
View File
@@ -1,24 +0,0 @@
name: poetry
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
poetry:
name: run poetry install to check lock file
runs-on: "ubuntu-24.04"
steps:
- uses: actions/checkout@v4
- name: set up python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- name: install poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
poetry install
+4 -4
View File
@@ -32,10 +32,6 @@ jobs:
run: |
docker build -t lnbits/lnbits .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
@@ -44,6 +40,10 @@ jobs:
./tests
sudo chmod -R a+rwx .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
-1
View File
@@ -61,4 +61,3 @@ dist
# jetbrains
.idea
.venv
+5 -5
View File
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 25.1.0
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.10
rev: v0.3.2
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.6.2
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
types_or: [css, javascript, html, json]
+20 -7
View File
@@ -2,20 +2,26 @@ FROM python:3.12-slim-bookworm AS builder
RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml uv.lock ./
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data
RUN uv sync --all-extras
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
FROM python:3.12-slim-bookworm
@@ -28,19 +34,26 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
RUN uv sync --all-extras
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+2 -4
View File
@@ -7,11 +7,9 @@ RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
+23 -23
View File
@@ -9,34 +9,34 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier:
uv run ./node_modules/.bin/prettier --write .
poetry run ./node_modules/.bin/prettier --write .
pyright:
uv run ./node_modules/.bin/pyright
poetry run ./node_modules/.bin/pyright
mypy:
uv run mypy
poetry run mypy
black:
uv run black .
poetry run black .
ruff:
uv run ruff check . --fix
poetry run ruff check . --fix
checkruff:
uv run ruff check .
poetry run ruff check .
checkprettier:
uv run ./node_modules/.bin/prettier --check .
poetry run ./node_modules/.bin/prettier --check .
checkblack:
uv run black --check .
poetry run black --check .
checkeditorconfig:
editorconfig-checker
dev:
uv run lnbits --reload
poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
@@ -46,27 +46,27 @@ test-wallets:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/wallets
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/unit
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/api
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/regtest
poetry run pytest tests/regtest
test-migration:
LNBITS_ADMIN_UI=True \
@@ -74,18 +74,18 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
uv run python tools/conv.py
poetry run python tools/conv.py
migration:
uv run python tools/conv.py
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
@@ -94,9 +94,9 @@ openapi:
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
PORT=5003 \
uv run lnbits &
poetry run lnbits &
sleep 15
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
# kill -9 %1
bak:
@@ -109,7 +109,7 @@ sass:
bundle:
npm install
npm run bundle
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
@@ -126,8 +126,8 @@ checkbundle:
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
uv run pre-commit run --all-files
poetry run pre-commit run --all-files
+1 -1
View File
@@ -23,4 +23,4 @@ if ! nc -z localhost 9002; then
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+6 -6
View File
@@ -11,13 +11,13 @@ Thanks for contributing :)
# Run
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
guide to install uv and other dependencies.
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
uv run lnbits
poetry run lnbits
```
Or you can use the following to start uvicorn with hot reloading enabled:
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
# or
uv run lnbits --reload
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
@@ -50,7 +50,7 @@ make install-pre-commit-hook
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
uv sync --all-extras --dev
poetry install
npm i
```
@@ -69,7 +69,7 @@ make format
Run mypy checks:
```bash
make mypy
poetry run mypy
```
Run everything:
+6 -2
View File
@@ -42,10 +42,14 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`:
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
```sh
$ poetry add <package>
```
**But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration
+1 -1
View File
@@ -53,7 +53,7 @@ $ sudo nano .env
Now start LNbits once in the terminal window
```
$ uv run lnbits
$ poetry run lnbits
```
You can now `cat` the Super User ID:
+26 -52
View File
@@ -15,23 +15,23 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
```sh
sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/arcbtc/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
sudo apt-get install libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
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 will create a folder for db and extension files in the folder the AppImage runs from.
## Option 2: UV (recommended for developers)
## Option 2: Poetry (recommended for developers)
It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
### Install Python 3.12
## Option 2 (recommended): UV
## Option 2 (recommended): Poetry
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### Verify Python version
@@ -39,19 +39,11 @@ It is recommended to use the latest version of UV. Make sure you have Python ver
python3 --version
```
### Install UV
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
```
### (old) Install Poetry
### Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
```
### install LNbits
@@ -59,13 +51,9 @@ export PATH="$HOME/.local/bin:$PATH"
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
poetry env use 3.12
git checkout main
uv sync --all-extras
# or poetry
# poetry env use 3.12
# poetry install --only main
poetry install --only main
cp .env.example .env
# Optional: to set funding source amongst other options via the env `nano .env`
```
@@ -73,11 +61,8 @@ cp .env.example .env
#### Running the server
```sh
uv run lnbits
# To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
# or poetry
# poetry run lnbits
poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
```
@@ -86,7 +71,7 @@ uv run lnbits
```sh
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
uv run lnbits-cli --help
poetry run lnbits-cli --help
```
#### Updating the server
@@ -100,18 +85,15 @@ cd lnbits
git pull --rebase
# Check your poetry version with
# poetry env list
poetry env list
# If version is less 3.12, update it by running
# poetry env use python3.12
# poetry env remove python3.9
# poetry env list
poetry env use python3.12
poetry env remove python3.9
poetry env list
# Run install and start LNbits with
# poetry install --only main
# poetry run lnbits
uv sync --all-extras
uv run lnbits
poetry install --only main
poetry run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
```
@@ -126,24 +108,16 @@ chmod +x lnbits.sh &&
Now visit `0.0.0.0:5000` to make a super-user account.
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `uv run lnbits` (see previous option).
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh
# 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 <(curl -L https://nixos.org/nix/install) --daemon
# 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' | 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
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
# Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install
@@ -168,7 +142,7 @@ but you can also set the env variables or pass command line arguments:
```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --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
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
@@ -358,7 +332,7 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# START LNbits
# STOP LNbits
uv run python tools/conv.py
poetry run python tools/conv.py
# or
make migration
```
@@ -383,8 +357,8 @@ Description=LNbits
[Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits
# same here. run `which uv` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/uv run lnbits
# same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on
User=lnbits
Restart=always
+1 -1
View File
@@ -72,7 +72,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
### LNbits
Generated
+61 -76
View File
@@ -1,39 +1,15 @@
{
"nodes": {
"build-system-pkgs": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
],
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1755484659,
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -42,49 +18,71 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"owner": "nixos",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pyproject-nix": {
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
]
],
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1754923840,
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"root": {
"inputs": {
"build-system-pkgs": "build-system-pkgs",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix_2"
"poetry2nix": "poetry2nix"
}
},
"systems": {
@@ -102,51 +100,38 @@
"type": "github"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"build-system-pkgs",
"nixpkgs"
],
"pyproject-nix": [
"build-system-pkgs",
"pyproject-nix"
]
},
"systems_2": {
"locked": {
"lastModified": 1755210905,
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
"id": "systems",
"type": "indirect"
}
},
"uv2nix_2": {
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
]
},
"locked": {
"lastModified": 1755485731,
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
+55 -135
View File
@@ -1,150 +1,70 @@
{
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
uv2nix.url = "github:pyproject-nix/uv2nix";
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
(system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
python = pkgs.python312;
# Read uv.lock / pyproject via uv2nix
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
# Prefer wheels when available
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
# Helper for extending lists safely (works if a is null)
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b);
# Extra build inputs for troublesome sdists
myOverrides = (final: prev: {
# embit needs setuptools at build time
embit = prev.embit.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# http-ece (pywebpush dep) needs setuptools
"http-ece" = prev."http-ece".overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# pyqrcode needs setuptools
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# tlv8 needs setuptools
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
forAllSystems = forSystems supportedSystems;
projectName = "lnbits";
in
{
overlays = {
default = final: prev: {
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
};
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.${projectName};
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# secp256k1 Python binding:
# - setuptools, pkg-config
# - cffi + pycparser
# - system libsecp256k1 for headers/libs
secp256k1 = prev.secp256k1.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools
pkgs.pkg-config
prev.cffi
prev.pycparser
];
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
});
# pynostr uses setuptools-scm for versioning
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ];
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools-scm
];
});
});
# Compose Python package set honoring uv.lock
pythonSet =
(pkgs.callPackage pyproject-nix.build.packages { inherit python; })
.overrideScope (lib.composeManyExtensions [
build-system-pkgs.overlays.default
uvLockedOverlay
myOverrides
]);
projectName = "lnbits";
# Build a venv from the locked spec (this installs the resolved wheels)
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
lnbitsApp = pkgs.writeShellApplication {
name = "lnbits";
text = ''
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits "$@"
'';
};
lnbitsCliApp = pkgs.writeShellApplication {
name = "lnbits-cli";
text = ''
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits-cli "$@"
'';
};
});
nixosModules = {
default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ];
};
};
checks = forAllSystems (system: pkgs:
let
vmTests = import ./nix/tests {
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
inherit inputs pkgs;
};
in
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
//
{
# nix build → produces the venv in ./result
packages.default = runtimeVenv;
packages.${projectName} = runtimeVenv;
# nix run . → launches via wrapper that imports from source tree
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
apps.${projectName} = self.apps.${system}.default;
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
# dev shell with locked deps + tools
devShells.default = pkgs.mkShell {
packages = [
runtimeVenv
pkgs.uv
pkgs.ruff
pkgs.black
pkgs.mypy
pkgs.pre-commit
pkgs.openapi-generator-cli
];
};
overlays.default = final: prev: {
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
};
# System-specific nixos modules to avoid circular dependency
nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ];
nixpkgs.overlays = [ self.overlays.${system}.default ];
};
checks = { };
});
# Other checks here...
}
);
};
}
+7 -6
View File
@@ -13,9 +13,10 @@ if [ ! -d lnbits/data ]; then
# Install Python 3.10 and distutils non-interactively
sudo apt install -y python3.10 python3.10-distutils
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.10 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
@@ -41,13 +42,13 @@ elif [ ! -d lnbits/wallets ]; then
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Install the dependencies using UV
uv sync --all-extras
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
uv run lnbits
poetry run lnbits
+6 -11
View File
@@ -4,10 +4,9 @@ import importlib
import os
import shutil
import sys
import time
from collections.abc import Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -27,7 +26,7 @@ from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
@@ -71,9 +70,8 @@ from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
@@ -104,7 +102,7 @@ async def startup(app: FastAPI):
# initialize tasks
register_async_tasks()
enqueue_admin_notification(
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
@@ -112,13 +110,10 @@ async def startup(app: FastAPI):
},
)
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_admin_notification(
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
@@ -280,7 +275,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: bool | None = True,
include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
+33 -32
View File
@@ -5,6 +5,7 @@ import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from typing import Optional
from uuid import uuid4
import click
@@ -95,7 +96,7 @@ def decrypt():
"""
def get_super_user() -> str | None:
def get_super_user() -> Optional[str]:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
if not superuser_file.exists() or not superuser_file.is_file():
@@ -154,7 +155,7 @@ async def db_versions():
@db.command("cleanup-wallets")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_wallets(days: int | None = None):
async def database_cleanup_wallets(days: Optional[int] = None):
"""Delete all wallets that never had any transaction"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
@@ -211,10 +212,10 @@ async def database_revert_payment(checking_id: str):
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro
async def check_invalid_payments(
days: int | None = None,
limit: int | None = None,
wallet: str | None = None,
verbose: bool | None = False,
days: Optional[int] = None,
limit: Optional[int] = None,
wallet: Optional[str] = None,
verbose: Optional[bool] = False,
):
"""Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings()
@@ -302,7 +303,7 @@ async def create_user(username: str, password: str):
@users.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: int | None = None):
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
@@ -352,12 +353,12 @@ async def extensions_list():
)
@coro
async def extensions_update( # noqa: C901
extension: str | None = None,
all_extensions: bool | None = False,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
):
"""
Update extension to the latest version.
@@ -442,10 +443,10 @@ async def extensions_update( # noqa: C901
@coro
async def extensions_install(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
@@ -472,7 +473,7 @@ async def extensions_install(
)
@coro
async def extensions_uninstall(
extension: str, url: str | None = None, admin_user: str | None = None
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
):
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
@@ -561,10 +562,10 @@ if __name__ == "__main__":
async def install_extension(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
@@ -590,10 +591,10 @@ async def install_extension(
async def update_extension(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
@@ -643,9 +644,9 @@ async def update_extension(
async def _select_release(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
) -> ExtensionRelease | None:
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
) -> Optional[ExtensionRelease]:
all_releases = await InstallableExtension.get_extension_releases(extension)
if len(all_releases) == 0:
click.echo(f"No repository found for extension '{extension}'.")
@@ -705,7 +706,7 @@ def _get_latest_release_per_repo(all_releases):
async def _call_install_extension(
data: CreateExtension, url: str | None, user_id: str | None = None
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
):
if url:
user_id = user_id or get_super_user()
@@ -719,7 +720,7 @@ async def _call_install_extension(
async def _call_uninstall_extension(
extension: str, url: str | None, user_id: str | None = None
extension: str, url: Optional[str], user_id: Optional[str] = None
):
if url:
user_id = user_id or get_super_user()
@@ -755,7 +756,7 @@ async def _can_run_operation(url) -> bool:
return True
async def _is_lnbits_started(url: str | None):
async def _is_lnbits_started(url: Optional[str]):
try:
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
async with httpx.AsyncClient() as client:
-4
View File
@@ -7,12 +7,10 @@ from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router
@@ -32,7 +30,6 @@ def init_core_routers(app: FastAPI):
app.include_router(admin_router)
app.include_router(node_router)
app.include_router(extension_router)
app.include_router(extension_builder_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(payment_router)
@@ -45,7 +42,6 @@ def init_core_routers(app: FastAPI):
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"]
+10 -8
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
@@ -6,14 +8,14 @@ from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
@@ -25,7 +27,7 @@ async def get_audit_entries(
async def delete_expired_audit_entries(
conn: Connection | None = None,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -38,8 +40,8 @@ async def delete_expired_audit_entries(
async def get_count_stats(
field: str,
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
@@ -65,8 +67,8 @@ async def get_count_stats(
async def get_long_duration_stats(
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
+6 -4
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
@@ -5,8 +7,8 @@ from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Connection | None = None
) -> DbVersion | None:
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
@@ -14,7 +16,7 @@ async def get_db_version(
)
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
@@ -28,7 +30,7 @@ async def update_migration_version(conn, db_name, version):
)
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
+17 -15
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
@@ -8,20 +10,20 @@ from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Connection | None = None
*, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
@@ -32,7 +34,7 @@ async def update_installed_extension_state(
async def delete_installed_extension(
*, ext_id: str, conn: Connection | None = None
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
@@ -42,7 +44,7 @@ async def delete_installed_extension(
)
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
@@ -63,8 +65,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
async def get_installed_extension(
ext_id: str, conn: Connection | None = None
) -> InstallableExtension | None:
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
@@ -74,8 +76,8 @@ async def get_installed_extension(
async def get_installed_extensions(
active: bool | None = None,
conn: Connection | None = None,
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions"
if active is not None:
@@ -91,8 +93,8 @@ async def get_installed_extensions(
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
@@ -104,7 +106,7 @@ async def get_user_extension(
async def get_user_extensions(
user_id: str, conn: Connection | None = None
user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
@@ -114,20 +116,20 @@ async def get_user_extensions(
async def create_user_extension(
user_extension: UserExtension, conn: Connection | None = None
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Connection | None = None
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Connection | None = None
user_id: str, conn: Optional[Connection] = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
+47 -45
View File
@@ -1,5 +1,5 @@
from time import time
from typing import Any
from typing import Any, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db
@@ -19,11 +19,16 @@ from ..models import (
)
def update_payment_extra():
pass
async def update_payment_extra(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
return await (conn or db).execute(
"UPDATE apipayments SET extra = :extra WHERE checking_id = :checking_id",
{"extra": payment.extra, "checking_id": payment.checking_id},
)
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
@@ -33,10 +38,10 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Connection | None = None,
incoming: bool | None = False,
wallet_id: str | None = None,
) -> Payment | None:
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
@@ -64,8 +69,8 @@ async def get_standalone_payment(
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None:
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment = await (conn or db).fetchone(
"""
SELECT *
@@ -102,17 +107,17 @@ async def get_latest_payments_by_extension(
async def get_payments_paginated( # noqa: C901
*,
wallet_id: str | None = None,
user_id: str | None = None,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: int | None = None,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
@@ -176,17 +181,17 @@ async def get_payments_paginated( # noqa: C901
async def get_payments(
*,
wallet_id: str | None = None,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: int | None = None,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
limit: int | None = None,
offset: int | None = None,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
@@ -230,7 +235,7 @@ async def get_payments_status_count() -> PaymentsStatusCount:
async def delete_expired_invoices(
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
@@ -259,7 +264,7 @@ async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
@@ -290,7 +295,7 @@ async def create_payment(
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
@@ -300,8 +305,8 @@ async def update_payment_checking_id(
async def update_payment(
payment: Payment,
new_checking_id: str | None = None,
conn: Connection | None = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
@@ -311,9 +316,9 @@ async def update_payment(
async def get_payments_history(
wallet_id: str | None = None,
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Filters | None = None,
filters: Optional[Filters] = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
@@ -376,11 +381,10 @@ async def get_payments_history(
async def get_payment_count_stats(
field: PaymentCountField,
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
@@ -409,11 +413,10 @@ async def get_payment_count_stats(
async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
@@ -459,11 +462,10 @@ async def get_daily_stats(
async def get_wallets_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
@@ -508,7 +510,7 @@ async def get_wallets_stats(
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Connection | None = None
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
@@ -517,8 +519,8 @@ async def delete_wallet_payment(
async def check_internal(
payment_hash: str, conn: Connection | None = None
) -> Payment | None:
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
@@ -534,7 +536,7 @@ async def check_internal(
async def is_internal_status_success(
payment_hash: str, conn: Connection | None = None
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
@@ -563,7 +565,7 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
async def _only_user_wallets_statement(
user_id: str, conn: Connection | None = None
user_id: str, conn: Optional[Connection] = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
+11 -9
View File
@@ -1,5 +1,5 @@
import json
from typing import Any
from typing import Any, Optional
from loguru import logger
@@ -14,7 +14,7 @@ from lnbits.settings import (
)
async def get_super_settings() -> SuperSettings | None:
async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
@@ -24,7 +24,7 @@ async def get_super_settings() -> SuperSettings | None:
return None
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings()
if not sets:
return None
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | Non
async def update_admin_settings(
data: EditableSettings, tag: str | None = "core"
data: EditableSettings, tag: Optional[str] = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
@@ -61,7 +61,7 @@ async def update_super_user(super_user: str) -> SuperSettings:
return settings
async def delete_admin_settings(tag: str | None = "core") -> None:
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
@@ -93,8 +93,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
async def get_settings_field(
id_: str, tag: str | None = "core"
) -> SettingsField | None:
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
row: dict = await db.fetchone(
"""
@@ -108,7 +108,9 @@ async def get_settings_field(
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
@@ -120,7 +122,7 @@ async def set_settings_field(id_: str, value: Any | None, tag: str | None = "cor
)
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
@@ -17,7 +19,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
+22 -20
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from time import time
from typing import Any
from typing import Any, Optional
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
@@ -18,8 +18,8 @@ from ..models import (
async def create_account(
account: Account | None = None,
conn: Connection | None = None,
account: Optional[Account] = None,
conn: Optional[Connection] = None,
) -> Account:
if account:
account.validate_fields()
@@ -36,7 +36,7 @@ async def update_account(account: Account) -> Account:
return account
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
async def get_accounts(
filters: Filters[AccountFilters] | None = None,
conn: Connection | None = None,
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AccountOverview]:
where_clauses = []
values: dict[str, Any] = {}
@@ -92,7 +92,9 @@ async def get_accounts(
)
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
@@ -104,7 +106,7 @@ async def get_account(user_id: str, conn: Connection | None = None) -> Account |
async def delete_accounts_no_wallets(
time_delta: int,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
@@ -123,8 +125,8 @@ async def delete_accounts_no_wallets(
async def get_account_by_username(
username: str, conn: Connection | None = None
) -> Account | None:
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(username) == 0:
return None
return await (conn or db).fetchone(
@@ -135,8 +137,8 @@ async def get_account_by_username(
async def get_account_by_pubkey(
pubkey: str, conn: Connection | None = None
) -> Account | None:
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
@@ -145,8 +147,8 @@ async def get_account_by_pubkey(
async def get_account_by_email(
email: str, conn: Connection | None = None
) -> Account | None:
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(email) == 0:
return None
return await (conn or db).fetchone(
@@ -157,8 +159,8 @@ async def get_account_by_email(
async def get_account_by_username_or_email(
username_or_email: str, conn: Connection | None = None
) -> Account | None:
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
@@ -169,7 +171,7 @@ async def get_account_by_username_or_email(
)
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
@@ -177,8 +179,8 @@ async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async def get_user_from_account(
account: Account, conn: Connection | None = None
) -> User | None:
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
@@ -205,7 +207,7 @@ async def update_user_access_control_list(user_acls: UserAcls):
async def get_user_access_control_lists(
user_id: str, conn: Connection | None = None
user_id: str, conn: Optional[Connection] = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
+23 -20
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
@@ -13,8 +14,8 @@ from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: str | None = None,
conn: Connection | None = None,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
@@ -31,8 +32,8 @@ async def create_wallet(
async def update_wallet(
wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
@@ -43,7 +44,7 @@ async def delete_wallet(
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
@@ -57,7 +58,9 @@ async def delete_wallet(
)
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -65,8 +68,8 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -80,13 +83,13 @@ async def delete_wallet_by_id(
return result.rowcount
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
@@ -104,8 +107,8 @@ async def delete_unused_wallets(
async def get_wallet(
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> Wallet | None:
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -122,7 +125,7 @@ async def get_wallet(
async def get_wallets(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
@@ -141,9 +144,9 @@ async def get_wallets(
async def get_wallets_paginated(
user_id: str,
deleted: bool | None = None,
filters: Filters[WalletsFilters] | None = None,
conn: Connection | None = None,
deleted: Optional[bool] = None,
filters: Optional[Filters[WalletsFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
@@ -163,7 +166,7 @@ async def get_wallets_paginated(
async def get_wallets_ids(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
@@ -183,8 +186,8 @@ async def get_wallets_count():
async def get_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> Wallet | None:
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
@@ -198,7 +201,7 @@ async def get_wallet_for_key(
)
async def get_total_balance(conn: Connection | None = None):
async def get_total_balance(conn: Optional[Connection] = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
@@ -5,7 +7,7 @@ from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> WebPushSubscription | None:
) -> Optional[WebPushSubscription]:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
+3 -3
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
@@ -20,7 +20,7 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
try:
@@ -38,7 +38,7 @@ async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: DbVersion | None = None,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
-8
View File
@@ -735,11 +735,3 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
+4 -7
View File
@@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .misc import (
BalanceDelta,
Callback,
@@ -9,7 +9,6 @@ from .misc import (
SimpleStatus,
)
from .payments import (
CancelInvoice,
CreateInvoice,
CreatePayment,
DecodePayment,
@@ -24,7 +23,6 @@ from .payments import (
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
SettleInvoice,
)
from .tinyurl import TinyURL
from .users import (
@@ -59,12 +57,11 @@ __all__ = [
"BalanceDelta",
"BaseWallet",
"Callback",
"CancelInvoice",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreateLnurl",
"CreateLnurlAuth",
"CreatePayment",
"CreateUser",
"CreateWallet",
@@ -75,6 +72,7 @@ __all__ = [
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"PayLnurlWData",
"Payment",
"PaymentCountField",
"PaymentCountStat",
@@ -87,7 +85,6 @@ __all__ = [
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance",
+14 -42
View File
@@ -39,7 +39,6 @@ class ExplicitRelease(BaseModel):
info_notification: str | None
critical_notification: str | None
details_link: str | None
paid_features: str | None
pay_link: str | None
def is_version_compatible(self):
@@ -188,7 +187,6 @@ class ExtensionRelease(BaseModel):
icon: str | None = None
details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
@@ -258,7 +256,6 @@ class ExtensionRelease(BaseModel):
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo,
icon=e.icon,
)
@@ -311,9 +308,6 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel):
@@ -415,6 +409,7 @@ class InstallableExtension(BaseModel):
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
@@ -457,23 +452,9 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True)
def check_release_updates(self, release: ExtensionRelease | None):
self._check_latest_version(release)
self._check_payment_link(release)
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _check_latest_version(self, release: ExtensionRelease | None):
def check_latest_version(self, release: ExtensionRelease | None):
if not release:
return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
@@ -484,19 +465,13 @@ class InstallableExtension(BaseModel):
):
self.meta.latest_release = release
def _check_payment_link(self, release: ExtensionRelease | None):
if not release:
return
if not release.is_version_compatible:
return
if not self.meta:
self.meta = ExtensionMeta()
if release.pay_link:
self.meta.has_paid_release = True
else:
self.meta.has_free_release = True
if release.paid_features:
self.meta.paid_features = release.paid_features
def 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 _restore_payment_info(self):
if (
@@ -622,7 +597,7 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext and ext.meta:
existing_ext.check_release_updates(ext.meta.latest_release)
existing_ext.check_latest_version(ext.meta.latest_release)
continue
meta = ext.meta or ExtensionMeta()
@@ -636,10 +611,10 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == e.id), None
)
if existing_ext:
existing_ext.check_release_updates(release)
existing_ext.check_latest_version(release)
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_release_updates(release)
ext.check_latest_version(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
@@ -653,11 +628,8 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
all_manifests = [
*settings.lnbits_extensions_manifests,
settings.lnbits_extensions_builder_manifest_url,
]
for url in all_manifests:
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
-460
View File
@@ -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
+13 -23
View File
@@ -1,31 +1,21 @@
from time import time
from typing import Optional
from lnurl import LnAddress, Lnurl, LnurlPayResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: str | None = None
unit: str | None = None
internal_memo: str | None = None
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
class CreateLnurlAuth(BaseModel):
callback: str
class LnurlScan(BaseModel):
lnurl: Lnurl | LnAddress
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []
class PayLnurlWData(BaseModel):
lnurl_w: str
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Callable
from pydantic import BaseModel
-3
View File
@@ -2,8 +2,6 @@ from enum import Enum
from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum):
server_status = "server_status"
@@ -20,7 +18,6 @@ class NotificationType(Enum):
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = {
+1 -42
View File
@@ -5,7 +5,6 @@ from enum import Enum
from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
@@ -253,25 +252,13 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
lnurl_callback: str | None = None
fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
@@ -285,31 +272,3 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0
failed: int = 0
pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+5 -18
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
@@ -20,15 +20,6 @@ from lnbits.settings import settings
from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
@@ -44,8 +35,6 @@ class UserExtra(BaseModel):
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
class EndpointAccess(BaseModel):
path: str
@@ -131,18 +120,16 @@ class Account(BaseModel):
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
salt = gensalt()
hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
return checkpw(password.encode(), self.password_hash.encode())
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
def validate_fields(self):
if self.username and not is_valid_username(self.username):
+12 -3
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from lnurl import encode as lnurl_encode
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -39,7 +41,6 @@ class Wallet(BaseModel):
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@property
def balance(self) -> int:
@@ -57,6 +58,14 @@ class Wallet(BaseModel):
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None
+5 -10
View File
@@ -2,11 +2,10 @@ from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
from .notifications import enqueue_admin_notification, send_payment_notification
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
@@ -18,7 +17,6 @@ from .payments import (
get_payments_daily_stats,
pay_invoice,
service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
@@ -38,7 +36,6 @@ from .websockets import websocket_manager, websocket_updater
__all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status",
"check_wallet_limits",
@@ -49,18 +46,16 @@ __all__ = [
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_admin_notification",
"enqueue_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"perform_withdraw",
"perform_lnurlauth",
"redeem_lnurl_withdraw",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
+6 -10
View File
@@ -1,5 +1,6 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
@@ -21,9 +22,7 @@ from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(
ext_info: InstallableExtension, skip_download: bool | None = False
) -> Extension:
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
@@ -37,8 +36,7 @@ async def install_extension(
if installed_ext and installed_ext.meta:
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()
@@ -76,13 +74,11 @@ async def uninstall_extension(ext_id: str):
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
await start_extension_background_work(ext.code)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await stop_extension_background_work(ext_id)
async def stop_extension_background_work(ext_id: str) -> bool:
@@ -149,7 +145,7 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions(
include_deactivated: bool | None = True,
include_deactivated: Optional[bool] = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
@@ -168,8 +164,8 @@ async def get_valid_extensions(
async def get_valid_extension(
ext_id: str, include_deactivated: bool | None = True
) -> Extension | None:
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext = await get_installed_extension(ext_id)
if not ext:
return None
-543
View File
@@ -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
+6 -10
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
@@ -14,7 +15,7 @@ from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Connection | None = None
payment: Payment, conn: Optional[Connection] = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
@@ -28,7 +29,7 @@ async def handle_fiat_payment_confirmation(
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Connection | None = None
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
@@ -65,7 +66,7 @@ async def _credit_fiat_service_fee_wallet(
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Connection | None = None
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
@@ -128,8 +129,8 @@ async def handle_stripe_event(event: dict):
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
sig_header: Optional[str],
secret: Optional[str],
tolerance_seconds=300,
):
if not sig_header:
@@ -175,11 +176,6 @@ async def test_connection(provider: str) -> SimpleStatus:
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
+3 -3
View File
@@ -1,7 +1,7 @@
from loguru import logger
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.notifications import enqueue_notification
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}."
)
enqueue_admin_notification(
enqueue_notification(
NotificationType.watchdog_check,
{
"delta_sats": status.delta_sats,
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_admin_notification(
enqueue_notification(
NotificationType.balance_delta,
{
"delta_sats": status.delta_sats,
+148 -156
View File
@@ -1,167 +1,159 @@
from time import time
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
from lnurl import (
LnAddress,
Lnurl,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
execute_pay_request,
execute_withdraw,
handle,
)
import httpx
from fastapi import Depends
from loguru import logger
from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.helpers import check_callback_url
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from .payments import create_invoice
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
"""
Perform an LNURL withdraw to the given LNURL-withdraw link.
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
:param payment_request: The BOLT11 payment request to pay.
:raises LnurlResponseException: If the LNURL-withdraw process fails.
"""
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlWithdrawResponse):
raise LnurlResponseException("Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
res2 = await execute_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
if not isinstance(res2, LnurlSuccessResponse):
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None
) -> str:
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
res2 = await execute_pay_request(
res,
msat=amount_msat,
comment=comment,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
return res2.pr
async def fetch_lnurl_pay_request(
data: CreateLnurlPayment, wallet: Wallet | None = None
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
"""
Pay an LNURL payment request.
optional `wallet` is used to store the pay link in the wallet's stored links.
raises `LnurlResponseException` if pay request fails
"""
if not data.res and data.lnurl:
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
data.res = res
if not data.res:
raise LnurlResponseException("No LNURL pay request provided.")
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
res2 = await execute_pay_request(
data.res,
msat=amount_msat,
comment=data.comment,
user_agent=settings.user_agent,
timeout=10,
)
if wallet:
await store_paylink(data.res, res2, wallet, data.lnurl)
return data.res, res2
async def store_paylink(
res: LnurlPayResponse,
res2: LnurlPayActionResponse,
wallet: Wallet,
lnurl: LnAddress | Lnurl | None = None,
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
if res2.disposable is not False:
return # do not store disposable LNURL pay links
res = {}
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl))
res = r.json()
stored_paylink = None
# If we have only a LnurlPayResponse, we can use its lnaddress
# because the lnurl is not available.
if not lnurl:
for _data in res.metadata.list():
if _data[0] == "text/identifier":
stored_paylink = StoredPayLink(
lnurl=LnAddress(_data[1]), label=res.metadata.text
)
if not stored_paylink:
logger.warning(
"No lnaddress found in metadata for LNURL pay link. "
"Skipping storage."
)
return # skip if lnaddress not found in metadata
else:
if isinstance(lnurl, Lnurl):
_lnurl = str(lnurl.lud17 or lnurl.bech32)
else:
_lnurl = str(lnurl)
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
# update last_used if its already stored
for pl in wallet.stored_paylinks.links:
if pl.lnurl == stored_paylink.lnurl:
pl.last_used = int(time())
await update_wallet(wallet)
logger.debug(
"Updated last used time for LNURL "
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
)
return
# if not already stored, append it
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
wallet.stored_paylinks.links.append(stored_paylink)
await update_wallet(wallet)
logger.debug(
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception as exc:
logger.debug(exc)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception as exc:
logger.debug(exc)
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
if not key.verifying_key:
raise ValueError("LNURLauth verifying_key does not exist")
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
+79 -155
View File
@@ -4,6 +4,7 @@ import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional
import httpx
from loguru import logger
@@ -15,14 +16,12 @@ from lnbits.core.crud import (
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
@@ -32,8 +31,8 @@ from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
if not _is_message_type_enabled(message_type):
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
if not is_message_type_enabled(message_type):
return
try:
notifications_queue.put_nowait(
@@ -43,125 +42,62 @@ def enqueue_admin_notification(message_type: NotificationType, values: dict) ->
logger.error(f"Error enqueuing notification: {e}")
def enqueue_user_notification(
message_type: NotificationType, values: dict, user_notifications: UserNotifications
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message)
user_notifications = notification_message.user_notifications
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: str | None = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: str | None = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
await send_notification(text, message_type)
async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: str | None = None,
message_type: Optional[str] = None,
) -> None:
try:
if telegram_chat_id and settings.is_telegram_notifications_configured():
await send_telegram_notification(telegram_chat_id, message)
if settings.lnbits_telegram_notifications_enabled:
await send_telegram_notification(message)
logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}")
try:
if nostr_identifiers and settings.is_nostr_notifications_configured():
await send_nostr_notifications(nostr_identifiers, message)
if settings.lnbits_nostr_notifications_enabled:
await send_nostr_notification(message)
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if email_addresses and settings.lnbits_email_notifications_enabled:
await send_email_notification(email_addresses, message)
if settings.lnbits_email_notifications_enabled:
await send_email_notification(message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
success_sent: list[str] = []
for identifier in identifiers:
async def send_nostr_notification(message: str) -> dict:
for i in settings.lnbits_nostr_notifications_identifiers:
try:
await send_nostr_notification(identifier, message)
success_sent.append(identifier)
identifier = await fetch_nip5_details(i)
user_pubkey = identifier[0]
relays = identifier[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
except Exception as e:
logger.warning(f"Error notifying identifier {identifier}: {e}")
return success_sent
logger.warning(f"Error notifying identifier {i}: {e}")
return {"status": "ok"}
async def send_nostr_notification(identifier: str, message: str):
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
async def send_telegram_notification(message: str) -> dict:
return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token,
chat_id,
settings.lnbits_telegram_notifications_chat_id,
message,
)
@@ -176,7 +112,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
async def send_email_notification(
to_emails: list[str], message: str, subject: str = "LNbits Notification"
message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
@@ -187,7 +123,7 @@ async def send_email_notification(
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
to_emails,
settings.lnbits_email_notifications_to_emails,
subject,
message,
)
@@ -227,6 +163,41 @@ async def send_email(
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
@@ -260,7 +231,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
await send_chat_payment_notification(wallet, payment)
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}")
try:
@@ -277,7 +248,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json())
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
@@ -288,15 +259,16 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send(wallet.inkey, payment_notification)
await websocket_manager.send(wallet.adminkey, payment_notification)
await websocket_manager.send(
payment.payment_hash,
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
)
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
@@ -311,23 +283,10 @@ async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_admin_notification(NotificationType.outgoing_payment, values)
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_admin_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
@@ -371,38 +330,3 @@ async def send_push_notification(subscription, title, body, url=""):
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
+81 -138
View File
@@ -1,12 +1,13 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
@@ -15,16 +16,15 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -57,11 +57,11 @@ async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: int | None = None,
extra: dict | None = None,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed")
@@ -90,8 +90,12 @@ async def pay_invoice(
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(wallet, payment, new_conn)
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
return payment
@@ -109,7 +113,7 @@ async def create_payment_request(
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
):
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
@@ -131,17 +135,12 @@ async def create_fiat_invoice(
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
if not fiat_provider:
raise InvoiceError("No fiat provider found.", status="failed")
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
extra=invoice_data.extra or {},
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
@@ -169,8 +168,8 @@ async def create_fiat_invoice(
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = None
unhashed_description = None
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
@@ -201,29 +200,30 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
)
if data.lnurl_withdraw:
try:
check_callback_url(data.lnurl_withdraw.callback)
res = await lnurl_withdraw(
data.lnurl_withdraw,
payment.bolt11,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = res.reason
payment.status = "failed"
elif isinstance(res, LnurlSuccessResponse):
payment.extra["lnurl_response"] = True
payment.status = "success"
except Exception as exc:
payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
@@ -232,16 +232,15 @@ async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: str | None = "sat",
currency: Optional[str] = "sat",
memo: str,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
expiry: int | None = None,
extra: dict | None = None,
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
conn: Connection | None = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -274,54 +273,39 @@ async def create_invoice(
status="failed",
)
if payment_hash:
try:
invoice_response = await funding_source.create_hold_invoice(
amount=amount_sat,
memo=invoice_memo,
payment_hash=payment_hash,
description_hash=description_hash,
)
extra["hold_invoice"] = True
except UnsupportedError as exc:
raise InvoiceError(
"Hold invoices are not supported by the funding source.",
status="failed",
) from exc
else:
invoice_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
payment_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if (
not invoice_response.ok
or not invoice_response.payment_request
or not invoice_response.checking_id
not payment_response.ok
or not payment_response.payment_request
or not payment_response.checking_id
):
raise InvoiceError(
message=invoice_response.error_message or "unexpected backend error.",
message=payment_response.error_message or "unexpected backend error.",
status="pending",
)
invoice = bolt11_decode(invoice_response.payment_request)
invoice = bolt11_decode(payment_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=invoice_response.payment_request,
bolt11=payment_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage,
preimage=payment_response.preimage,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
fee=payment_response.fee_msat or 0,
)
payment = await create_payment(
checking_id=invoice_response.checking_id,
checking_id=payment_response.checking_id,
data=create_payment_model,
conn=conn,
)
@@ -428,7 +412,7 @@ def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
async def update_wallet_balance(
wallet: Wallet,
amount: int,
conn: Connection | None = None,
conn: Optional[Connection] = None,
):
if amount == 0:
raise ValueError("Amount cannot be 0.")
@@ -487,14 +471,14 @@ async def update_wallet_balance(
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Connection | None = None
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(
wallet_id: str, conn: Connection | None = None
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
@@ -514,7 +498,7 @@ async def check_time_limit_between_transactions(
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Connection | None = None
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
@@ -547,8 +531,8 @@ async def check_wallet_daily_withdraw_limit(
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
currency: str | None = None,
extra: dict | None = None,
currency: Optional[str] = None,
extra: Optional[dict] = None,
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
@@ -583,9 +567,9 @@ async def calculate_fiat_amounts(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Connection | None = None
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Payment | None = await get_wallet_payment(
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
@@ -599,7 +583,7 @@ async def check_transaction_status(
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
user_id: str | None = None,
user_id: Optional[str] = None,
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
balance_total: float = 0
@@ -648,7 +632,7 @@ async def get_payments_daily_stats(
async def _pay_invoice(
wallet_id: str,
create_payment_model: CreatePayment,
conn: Connection | None = None,
conn: Optional[Connection] = None,
):
async with payment_lock:
if wallet_id not in wallets_payments_lock:
@@ -671,8 +655,8 @@ async def _pay_invoice(
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Connection | None = None,
) -> Payment | None:
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
@@ -739,7 +723,7 @@ async def _pay_internal_invoice(
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
@@ -808,7 +792,7 @@ async def _pay_external_invoice(
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Connection | None = None,
conn: Optional[Connection] = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
@@ -832,7 +816,7 @@ async def _fundingsource_pay_invoice(
async def _verify_external_payment(
payment: Payment, conn: Connection | None = None
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
@@ -863,7 +847,7 @@ async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Connection | None = None,
conn: Optional[Connection] = None,
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
@@ -879,7 +863,7 @@ async def _check_wallet_for_payment(
def _validate_payment_request(
payment_request: str, max_sat: int | None = None
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
@@ -902,16 +886,12 @@ def _validate_payment_request(
async def _credit_service_fee_wallet(
wallet: Wallet, payment: Payment, conn: Connection | None = None
payment: Payment, memo: str, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
@@ -928,7 +908,7 @@ async def _credit_service_fee_wallet(
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Connection | None = None
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
@@ -969,40 +949,3 @@ async def _check_fiat_invoice_limits(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response
+7 -6
View File
@@ -1,4 +1,5 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
@@ -36,7 +37,7 @@ from .settings import update_cached_settings
async def create_user_account(
account: Account | None = None, wallet_name: str | None = None
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
@@ -45,9 +46,9 @@ async def create_user_account(
async def create_user_account_no_ckeck(
account: Account | None = None,
wallet_name: str | None = None,
default_exts: list[str] | None = None,
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
) -> User:
if account:
@@ -164,12 +165,12 @@ async def check_admin_settings():
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
+12 -50
View File
@@ -1,65 +1,27 @@
from asyncio import Queue
from dataclasses import dataclass
from fastapi import WebSocket, WebSocketDisconnect
from fastapi import WebSocket
from loguru import logger
from lnbits.settings import settings
@dataclass
class WebsocketConnection:
item_id: str
websocket: WebSocket
receive_queue: Queue[str]
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebsocketConnection] = []
self.active_connections: list[WebSocket] = []
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection:
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
conn = WebsocketConnection(
item_id=item_id,
websocket=websocket,
receive_queue=Queue(),
)
self.active_connections.append(conn)
return conn
self.active_connections.append(websocket)
async def listen(self, conn: WebsocketConnection) -> None:
while settings.lnbits_running:
try:
data = await conn.websocket.receive_text()
logger.debug(f"WS received data from {conn.item_id}: {data}")
conn.receive_queue.put_nowait(data)
except WebSocketDisconnect:
for _conn in self.active_connections:
if _conn.websocket == conn.websocket:
self.active_connections.remove(_conn)
logger.debug(f"WS disconnected from {conn.item_id}")
break # out of the listen and the fastapi route
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
def get_connections(self, item_id: str) -> list[WebsocketConnection]:
conns = []
for conn in self.active_connections:
if conn.item_id == item_id:
conns.append(conn)
return conns
def has_connection(self, item_id: str) -> bool:
return len(self.get_connections(item_id)) > 0
async def send(self, item_id: str, data: str) -> None:
for conn in self.get_connections(item_id):
await conn.websocket.send_text(data)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
# deprecated import and use `websocket_manager.send()` instead
async def websocket_updater(item_id: str, data: str) -> None:
return await websocket_manager.send(item_id, data)
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
+5 -4
View File
@@ -1,6 +1,7 @@
import asyncio
import traceback
from collections.abc import Callable, Coroutine
from collections.abc import Coroutine
from typing import Callable
from loguru import logger
@@ -21,7 +22,7 @@ from lnbits.core.services.funding_source import (
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_admin_notification,
enqueue_notification,
process_next_notification,
send_payment_notification,
)
@@ -86,7 +87,7 @@ async def _notify_server_status() -> None:
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
}
enqueue_admin_notification(NotificationType.server_status, values)
enqueue_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
@@ -122,7 +123,7 @@ async def wait_notification_messages() -> None:
try:
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
logger.log("error", ex)
await asyncio.sleep(3)
@@ -84,27 +84,6 @@
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions_builder')"></span>
</q-item-label>
<q-item-label caption>
<span
v-text="$t('misc_disable_extensions_builder_label')"
></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_builder_activate_non_admins"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
@@ -126,17 +105,6 @@
</q-item>
<br />
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('extension_builder_manifest_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_builder_manifest_url"
:label="$t('extension_builder_manifest_url')"
:hint="$t('extension_builder_manifest_url_hint')"
></q-input>
</div>
</div>
</div>
</q-card-section>
+4 -2
View File
@@ -53,7 +53,8 @@
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:show-buttons="false"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
@@ -85,7 +86,8 @@
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:show-buttons="false"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
+44 -156
View File
@@ -7,71 +7,38 @@
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
<div>
<div class="q-gutter-y-md">
<q-tabs v-model="tab" align="left">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="user"
icon="person"
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('account_settings')"></span
></q-tooltip>
</q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span
></q-tooltip>
</q-tab>
<q-tab
name="theme"
icon="palette"
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('look_and_feel')"></span
></q-tooltip>
</q-tab>
<q-tab
name="api_acls"
icon="lock"
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('access_control_list')"></span
></q-tooltip>
</q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-scroll-area style="height: 80vh">
<q-tab-panels v-if="user" v-model="tab">
<q-card-section>
<div class="row">
<div class="col">
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<q-card-section>
@@ -326,6 +293,9 @@
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('change_password')"
@@ -589,101 +559,12 @@
</q-select>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="notifications">
<q-card-section>
<div class="row q-mb-md">
<div class="col-4">
<span
v-text="$t('notifications_nostr_identifier')"
></span>
{%if not nostr_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.nostr_identifier"
:hint="$t('notifications_nostr_identifier_desc')"
>
</q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications_chat_id')"></span>
{%if not telegram_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.telegram_chat_id"
:hint="$t('notifications_chat_id_desc')"
/>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_outgoing_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.outgoing_payments_sats"
:hint="$t('notification_outgoing_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_incoming_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.incoming_payments_sats"
:hint="$t('notification_incoming_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('exclude_wallets')"></span>
</div>
<div class="col-8">
<q-select
filled
dense
emit-value
map-options
multiple
v-model="user.extra.notifications.excluded_wallets"
:options="g.user.walletOptions"
:label="$t('exclude_wallets')"
:hint="$t('notifications_excluded_wallets_desc')"
class="q-mt-sm"
>
</q-select>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
@@ -910,9 +791,16 @@
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
</template>
</q-splitter>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-card>
</div>
</div>
+15 -53
View File
@@ -24,7 +24,6 @@
v-text="$t('only_admins_can_install')"
></i>
<q-space></q-space>
<q-input
:label="$t('search_extensions')"
:dense="dense"
@@ -54,18 +53,6 @@
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
></span>
</q-badge>
{% if extension_builder_enabled %}
<q-btn flat no-caps icon="architecture" to="/extensions/builder"
><span v-text="$t('create_extension')"></span
></q-btn>
{% else %}
<q-btn disabled flat no-caps icon="architecture"
><span v-text="$t('create_extension')"></span>
<q-tooltip
v-text="$t('only_admins_can_create_extensions')"
></q-tooltip>
</q-btn>
{% endif %}
<q-btn
v-if="g.user.admin"
flat
@@ -137,40 +124,8 @@
@click="showExtensionDetails(extension.id, extension.details_link)"
v-text="extension.name"
></div>
<div style="justify-content: space-between; display: flex">
<div>
<lnbits-extension-rating :rating="0" />
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
color="green"
size="xs"
:label="$t('free')"
>
<q-tooltip>
<span v-text="$t('extension_has_free_release')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-if="extension.hasPaidRelease || extension.paidFeatures"
color="primary"
size="xs"
:label="$t('paid')"
>
<q-tooltip>
<span
v-if="extension.hasPaidRelease"
v-text="$t('extension_has_paid_release')"
></span>
<br
v-if="extension.hasPaidRelease && extension.paidFeatures"
/>
<span
v-if="extension.paidFeatures"
v-text="extension.paidFeatures"
></span>
</q-tooltip>
</q-btn>
</q-btn-group>
</div>
<div style="justify-content: space-between; display: flex">
<q-toggle
@@ -373,10 +328,13 @@
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedRelease.paymentRequest"
></lnbits-qrcode>
<a :href="'lightning:' + selectedRelease.paymentRequest">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
@@ -744,10 +702,13 @@
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
<div class="row q-mt-lg">
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
></lnbits-qrcode>
>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div v-else class="col">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
@@ -948,6 +909,7 @@
:href="selectedExtensionDetails.repo"
target="_blank"
rel="noopener noreferrer"
class="q-pr-xs"
><q-tooltip>repository</q-tooltip></q-btn
>
</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>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}
+49 -114
View File
@@ -209,6 +209,16 @@
></payment-list>
</q-card-section>
</q-card>
<div id="hiddenQrCodeContainer" style="display: none">
<lnbits-qrcode
v-if="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
></lnbits-qrcode>
<lnbits-qrcode
v-else
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
></lnbits-qrcode>
</div>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
@@ -267,101 +277,21 @@
:label="$t('drain_funds')"
>
<q-card>
<q-card-section>
<lnbits-qrcode
value="lightning:{{wallet.lnurlwithdraw_full}}"
href="lightning:{{wallet.lnurlwithdraw_full}}"
></lnbits-qrcode>
<p
class="text-center"
v-text="$t('drain_funds_desc')"
></p>
<q-card-section class="text-center">
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<lnbits-qrcode
:value="lightning:{{wallet.lnurlwithdraw_full}}"
></lnbits-qrcode>
</a>
<p v-text="$t('drain_funds_desc')"></p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="qr_code"
v-if="stored_paylinks.length > 0"
:label="$t('stored_paylinks')"
>
<q-card>
<q-card-section>
<div class="row flex" v-for="paylink in stored_paylinks">
<q-btn
dense
flat
color="primary"
icon="send"
size="xs"
@click="sendToPaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`send to: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<q-btn
dense
flat
color="secondary"
icon="content_copy"
size="xs"
@click="copyText(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`copy: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<span
v-text="paylink.label"
class="q-mr-xs q-ml-xs"
></span>
<q-btn dense flat color="primary" icon="edit" size="xs">
<q-popup-edit
@update:model-value="editPaylink()"
v-model="paylink.label"
v-slot="scope"
>
<q-input
dark
color="white"
v-model="scope.value"
dense
autofocus
counter
@keyup.enter="scope.set"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>
<span v-text="$t('edit')"></span>
</q-tooltip>
</q-btn>
<span style="flex-grow: 1"></span>
<q-btn
dense
flat
color="red"
icon="delete"
size="xs"
@click="deletePaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
<span v-text="dateFromNow(paylink.last_used)"></span>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
group="extras"
icon="phone_android"
:label="$t('access_wallet_on_mobile')"
@@ -403,15 +333,21 @@
:label="$t('export_to_phone')"
>
<q-card>
<q-card-section>
<p
class="text-center"
v-text="$t('export_to_phone_desc')"
></p>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<lnbits-qrcode
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
</q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
</q-card-section>
@@ -725,13 +661,7 @@
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
v-if="has_holdinvoice"
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input
filled
dense
@@ -848,19 +778,18 @@
v-else-if="receive.paymentReq && receive.lnurl == null"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<lnbits-qrcode
v-if="receive.fiatPaymentReq"
:show-buttons="false"
:href="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
>
</lnbits-qrcode>
<lnbits-qrcode
v-else
:href="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq"
>
</lnbits-qrcode>
<div class="text-center q-mb-lg">
<a
v-if="receive.fiatPaymentReq"
:href="receive.fiatPaymentReq"
target="_blank"
>
<div v-html="invoiceQrCode"></div>
</a>
<a v-else :href="'lightning:' + receive.paymentReq">
<div v-html="invoiceQrCode"></div>
</a>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
@@ -885,6 +814,12 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
+8 -14
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from typing import IO, Optional
from urllib.parse import urlparse
import filetype
@@ -16,7 +16,7 @@ from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_admin_notification,
enqueue_notification,
get_balance_delta,
update_cached_settings,
)
@@ -65,16 +65,14 @@ async def api_monitor():
)
async def api_test_email():
return await send_email_notification(
settings.lnbits_email_notifications_to_emails,
"This is a LNbits test email.",
"LNbits Test Email",
"This is a LNbits test email.", "LNbits Test Email"
)
@admin_router.get("/api/v1/settings")
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
) -> AdminSettings | None:
) -> Optional[AdminSettings]:
admin_settings = await get_admin_settings(user.super_user)
return admin_settings
@@ -84,9 +82,7 @@ async def api_get_settings(
status_code=HTTPStatus.OK,
)
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
enqueue_notification(NotificationType.settings_update, {"username": user.username})
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
if not admin_settings:
@@ -117,9 +113,7 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
enqueue_notification(NotificationType.settings_update, {"username": user.username})
await reset_core_settings()
server_restart.set()
@@ -147,7 +141,7 @@ async def api_download_backup() -> FileResponse:
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
is_pg = db_url and db_url.startswith("postgres://")
if is_pg and db_url:
if is_pg:
p = urlparse(db_url)
command = (
f"pg_dump --host={p.hostname} "
+157 -3
View File
@@ -1,20 +1,36 @@
import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import APIRouter, Depends
from fastapi import (
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateLnurlAuth,
CreateWallet,
User,
Wallet,
)
from lnbits.decorators import check_user_exists
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
@@ -25,7 +41,7 @@ from lnbits.utils.exchange_rates import (
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account
from ..services import create_user_account, perform_lnurlauth
api_router = APIRouter(tags=["Core"])
@@ -76,6 +92,144 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan( # noqa: C901
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
if not lnurlauth_key.verifying_key:
raise ValueError("LNURL auth key not found for this domain.")
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(check_user_exists)],
+12 -12
View File
@@ -1,9 +1,9 @@
import base64
import importlib
import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Callable, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -238,7 +238,7 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request, provider: str, user_id: str | None = None
request: Request, provider: str, user_id: Optional[str] = None
):
provider_sso = _new_sso(provider)
if not provider_sso:
@@ -319,7 +319,7 @@ async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None:
) -> Optional[User]:
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
@@ -345,7 +345,7 @@ async def update_password(
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None:
) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
@@ -419,7 +419,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
@auth_router.put("/update")
async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> User | None:
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
@@ -461,7 +461,7 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = None):
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email
if not email or not is_valid_email_address(email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
@@ -490,9 +490,9 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
def _auth_success_response(
username: str | None = None,
user_id: str | None = None,
email: str | None = None,
username: Optional[str] = None,
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
@@ -533,7 +533,7 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
return response
def _new_sso(provider: str) -> SSOBase | None:
def _new_sso(provider: str) -> Optional[SSOBase]:
try:
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
return None
@@ -610,7 +610,7 @@ def _nostr_nip98_event(request: Request) -> dict:
def _check_nostr_event_tags(event: dict):
method: str | None = next((v for k, v in event["tags"] if k == "method"), None)
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
if not method:
raise ValueError("Tag 'method' is missing.")
if not method.upper() == "POST":
@@ -625,7 +625,7 @@ def _check_nostr_event_tags(event: dict):
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
def _validate_auth_timeout(auth_time: int | None = 0):
def _validate_auth_timeout(auth_time: Optional[int] = 0):
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
+9 -6
View File
@@ -3,7 +3,11 @@ import traceback
from http import HTTPStatus
from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
@@ -61,10 +65,9 @@ async def api_install_extension(data: CreateExtension):
data.ext_id, data.source_repo, data.archive, data.version
)
if not release:
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
if not release.is_version_compatible:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
@@ -228,7 +231,7 @@ async def api_disable_extension(
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabling extension: {ext_id}.")
logger.info(f"Disabeling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
-164
View File
@@ -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 -28
View File
@@ -1,11 +1,10 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
from lnbits.fiat import StripeWallet, get_fiat_provider
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@@ -17,29 +16,3 @@ fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
)
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
@fiat_router.post(
"/{provider}/connection_token",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def connection_token(provider: str):
provider_wallet = await get_fiat_provider(provider)
if provider == "stripe":
if not isinstance(provider_wallet, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await provider_wallet.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException(
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e
+9 -98
View File
@@ -1,7 +1,5 @@
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path
from typing import Annotated
from typing import Annotated, Optional, Union
from urllib.parse import urlencode, urlparse
import httpx
@@ -9,7 +7,7 @@ from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import url_decode
from lnurl import decode as lnurl_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
@@ -126,9 +124,6 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
@@ -154,95 +149,11 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
{
"user": user.json(),
"extension_data": extension_data,
"extension_builder_enabled": user.admin
or settings.lnbits_extensions_builder_activate_non_admins,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
)
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
return template_renderer().TemplateResponse(
request,
"core/extensions_builder.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
name="extensions builder",
response_class=HTMLResponse,
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
page_name: str | None = None,
user: User = Depends(check_user_exists),
):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path(
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"err": f"Extension {ext_id} not found",
"message": "Please 'Refresh Preview' first.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
)
return response
@generic_router.get(
"/wallet",
response_class=HTMLResponse,
@@ -250,9 +161,9 @@ async def extensions_builder_preview(
)
async def wallet(
request: Request,
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
user: User = Depends(check_user_exists),
wal: UUID4 | None = Query(None),
wal: Optional[UUID4] = Query(None),
):
if wal:
wallet = await get_wallet(wal.hex)
@@ -295,15 +206,11 @@ async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@@ -434,6 +341,7 @@ async def node(request: Request, user: User = Depends(check_admin)):
"node/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
@@ -453,6 +361,7 @@ async def node_public(request: Request):
request,
"node/public.html",
{
"settings": settings.dict(),
"balance": balance,
},
)
@@ -471,6 +380,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
"admin/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
@@ -488,6 +398,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
{
"request": request,
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
@@ -545,7 +456,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = url_decode(lightning)
lnurl = lnurl_decode(lightning)
async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
-136
View File
@@ -1,136 +0,0 @@
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from lnurl import LnurlResponseException
from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return res
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
deprecated=True,
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
res = await _handle(code)
if isinstance(res, LnurlPayResponse | LnurlWithdrawResponse | LnurlAuthResponse):
check_callback_url(res.callback)
return res
@lnurl_router.post(
"/api/v1/lnurlscan",
dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
return await _handle(scan.lnurl)
@lnurl_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> LnurlResponseModel:
check_callback_url(data.callback)
try:
res = await lnurlauth(
res=data,
seed=key_type.wallet.adminkey,
user_agent=settings.user_agent,
timeout=5,
)
return res
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
"""
Pay an LNURL payment request.
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
"""
if not data.res and not data.lnurl:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Missing LNURL or LnurlPayResponse data.",
)
try:
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
extra: dict[str, Any] = {}
if res2.disposable is False:
extra["stored"] = True
if res2.successAction:
extra["success_action"] = res2.successAction.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res2.pr),
description=res.metadata.text,
extra=extra,
)
return payment
+25 -29
View File
@@ -1,4 +1,5 @@
from http import HTTPStatus
from typing import Optional
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -6,9 +7,8 @@ from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import Feature
from ...db import Filters, Page
from ...nodes.base import (
@@ -26,13 +26,9 @@ from ...nodes.base import (
from ...utils.cache import cache
def require_node() -> Node:
funding_source = get_funding_source()
if (
not funding_source.features
or Feature.nodemanager not in funding_source.features
or not funding_source.__node_cls__
):
def require_node():
node_class = get_node_class()
if not node_class:
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API",
@@ -42,7 +38,7 @@ def require_node() -> Node:
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
return funding_source.__node_cls__(funding_source)
return node_class
def check_public():
@@ -88,14 +84,14 @@ async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeI
@node_router.get("/info")
async def api_get_info(
node: Node = Depends(require_node),
) -> NodeInfoResponse | None:
) -> Optional[NodeInfoResponse]:
return await node.get_info()
@node_router.get("/channels")
async def api_get_channels(
node: Node = Depends(require_node),
) -> list[NodeChannel] | None:
) -> Optional[list[NodeChannel]]:
return await node.get_channels()
@@ -103,7 +99,7 @@ async def api_get_channels(
async def api_get_channel(
channel_id: str,
node: Node = Depends(require_node),
) -> NodeChannel | None:
) -> Optional[NodeChannel]:
return await node.get_channel(channel_id)
@@ -112,20 +108,20 @@ async def api_create_channel(
node: Node = Depends(require_node),
peer_id: str = Body(),
funding_amount: int = Body(),
push_amount: int | None = Body(None),
fee_rate: int | None = Body(None),
push_amount: Optional[int] = Body(None),
fee_rate: Optional[int] = Body(None),
):
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
@super_node_router.delete("/channels")
async def api_delete_channel(
short_id: str | None,
funding_txid: str | None,
output_index: int | None,
short_id: Optional[str],
funding_txid: Optional[str],
output_index: Optional[int],
force: bool = False,
node: Node = Depends(require_node),
) -> list[NodeChannel] | None:
) -> Optional[list[NodeChannel]]:
return await node.close_channel(
short_id,
(
@@ -151,7 +147,7 @@ async def api_set_channel_fees(
async def api_get_payments(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
) -> Page[NodePayment] | None:
) -> Optional[Page[NodePayment]]:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
@@ -164,7 +160,7 @@ async def api_get_payments(
async def api_get_invoices(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
) -> Page[NodeInvoice] | None:
) -> Optional[Page[NodeInvoice]]:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
@@ -191,25 +187,25 @@ async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
class NodeRank(BaseModel):
capacity: int | None
channelcount: int | None
age: int | None
growth: int | None
availability: int | None
capacity: Optional[int]
channelcount: Optional[int]
age: Optional[int]
growth: Optional[int]
availability: Optional[int]
# Same for public and private api
@node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=NodeRank | None,
response_model=Optional[NodeRank],
)
@public_node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=NodeRank | None,
response_model=Optional[NodeRank],
)
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> NodeRank | None:
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
node_id = await node.get_id()
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
+186 -61
View File
@@ -1,6 +1,11 @@
from hashlib import sha256
import json
import ssl
from http import HTTPStatus
from math import ceil
from typing import Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Depends,
@@ -9,7 +14,7 @@ from fastapi import (
Query,
)
from fastapi.responses import JSONResponse
from lnurl import url_decode
from loguru import logger
from lnbits import bolt11
from lnbits.core.crud.payments import (
@@ -17,11 +22,11 @@ from lnbits.core.crud.payments import (
get_wallets_stats,
)
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
CreateLnurlWithdraw,
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentCountField,
PaymentCountStat,
@@ -29,8 +34,6 @@ from lnbits.core.models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
)
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
@@ -42,10 +45,13 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import (
check_callback_url,
filter_dict_keys,
generate_filter_params_openapi,
)
from lnbits.wallets.base import InvoiceResponse
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from ..crud import (
DateTrunc,
@@ -54,15 +60,13 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_payment_extra,
)
from ..services import (
cancel_hold_invoice,
create_payment_request,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
perform_withdraw,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
)
@@ -260,6 +264,38 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data)
@payment_router.patch(
"/extra/{payment_hash}", description="Update extra data for a payment"
)
async def api_payments_update_extra(
payment_hash: str,
extra: dict,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(payment_hash, wallet_id=wallet.wallet.id)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
if not isinstance(extra, dict):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Extra data must be a dictionary.",
)
if payment.extra is None:
payment.extra = {}
for key, value in extra.items():
if key in payment.extra:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Key '{key}' already exists in extra data.",
)
payment.extra[key] = value
return await update_payment_extra(payment)
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
@@ -275,9 +311,95 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
)
def _validate_lnurl_response(
params: dict, domain: str, amount_msat: int
) -> bolt11.Invoice:
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
return invoice
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
r: httpx.Response = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
return json.loads(r.text)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
check_callback_url(data.callback)
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
params = await _fetch_lnurl_params(data, amount_msat, domain)
_validate_lnurl_response(params, domain, amount_msat)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
if data.internal_memo is not None:
if len(data.internal_memo) > 512:
raise ValueError("Internal memo must be 512 characters or less.")
extra["internal_memo"] = data.internal_memo
if data.description is None:
raise ValueError("Description is required")
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
# TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
@@ -296,9 +418,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": True, "preimage": payment.preimage}
if payment.failed:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
return {"paid": False, "status": "failed", "details": payment}
try:
status = await payment.check_status()
@@ -322,7 +442,7 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = str(url_decode(payment_str))
url = str(lnurl_decode(payment_str))
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
@@ -335,52 +455,57 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
)
@payment_router.post("/settle")
async def api_payments_settle(
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
payment = await get_standalone_payment(
payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await settle_hold_invoice(payment, data.preimage)
@payment_router.post("/cancel")
async def api_payments_cancel(
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment = await get_standalone_payment(
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await cancel_hold_invoice(payment)
@payment_router.post("/{payment_request}/pay-with-nfc")
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> SimpleStatus:
if not lnurl_data.lnurl_w.lud17:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="LNURL-withdraw lud17 not provided.",
)
try:
await perform_withdraw(lnurl_data.lnurl_w.lud17, payment_request)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
return SimpleStatus(success=True, message="Payment sent with NFC.")
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
+4 -10
View File
@@ -2,6 +2,7 @@ import base64
import json
import time
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
import shortuuid
@@ -34,7 +35,7 @@ from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import (
create_user_account_no_ckeck,
enqueue_admin_notification,
enqueue_notification,
update_user_account,
update_user_extensions,
update_wallet_balance,
@@ -47,7 +48,6 @@ from lnbits.helpers import (
)
from lnbits.settings import EditableSettings, settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.utils.nostr import normalize_public_key
users_router = APIRouter(
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
@@ -95,9 +95,6 @@ async def api_create_user(data: CreateUser) -> CreateUser:
data.extra = data.extra or UserExtra()
data.extra.provider = data.extra.provider or "lnbits"
if data.pubkey:
data.pubkey = normalize_public_key(data.pubkey)
account = Account(
id=uuid4().hex,
username=data.username,
@@ -131,9 +128,6 @@ async def api_update_user(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
)
if data.pubkey:
data.pubkey = normalize_public_key(data.pubkey)
account = Account(
id=user_id,
username=data.username,
@@ -229,7 +223,7 @@ async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
async def api_users_create_user_wallet(
user_id: str, name: str | None = Body(None), currency: str | None = Body(None)
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
):
if currency and currency not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
@@ -327,7 +321,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
if not wallet:
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
enqueue_admin_notification(
enqueue_notification(
NotificationType.balance_update,
{
"amount": int(data.amount),
+8 -22
View File
@@ -1,4 +1,5 @@
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
from fastapi import (
@@ -9,11 +10,11 @@ from fastapi import (
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
@@ -92,28 +93,13 @@ async def api_reset_wallet_keys(
return wallet
@wallet_router.put("/stored_paylinks/{wallet_id}")
async def api_put_stored_paylinks(
wallet_id: str,
data: StoredPayLinks,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[StoredPayLink]:
if key_info.wallet.id != wallet_id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
)
key_info.wallet.stored_paylinks.links = data.links
wallet = await update_wallet(key_info.wallet)
return wallet.stored_paylinks.links
@wallet_router.patch("")
async def api_update_wallet(
name: str | None = Body(None),
icon: str | None = Body(None),
color: str | None = Body(None),
currency: str | None = Body(None),
pinned: bool | None = Body(None),
name: Optional[str] = Body(None),
icon: Optional[str] = Body(None),
color: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
pinned: Optional[bool] = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
+20 -7
View File
@@ -1,20 +1,33 @@
from fastapi import APIRouter, WebSocket
from fastapi import (
APIRouter,
WebSocket,
WebSocketDisconnect,
)
from ..services import websocket_manager
from lnbits.settings import settings
from ..services import (
websocket_manager,
websocket_updater,
)
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@websocket_router.websocket("/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
conn = await websocket_manager.connect(item_id, websocket)
await websocket_manager.listen(conn)
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocket_manager.connect(websocket, item_id)
try:
while settings.lnbits_running:
await websocket.receive_text()
except WebSocketDisconnect:
websocket_manager.disconnect(websocket)
@websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocket_manager.send(item_id, data)
await websocket_updater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
@@ -23,7 +36,7 @@ async def websocket_update_post(item_id: str, data: str):
@websocket_router.get("/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocket_manager.send(item_id, data)
await websocket_updater(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
+5 -9
View File
@@ -281,19 +281,15 @@ class Database(Compat):
self.schema = self.name
self.type = DB_TYPE
if self.type == POSTGRES and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
elif self.type == COCKROACH and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"cockroachdb://", "cockroachdb+asyncpg://"
)
else:
if DB_TYPE == SQLITE:
self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3"
)
database_uri = f"sqlite+aiosqlite:///{self.path}"
else:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"):
self.schema = self.name[4:]
+21 -21
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Annotated, Literal
from typing import Annotated, Literal, Optional, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
@@ -55,8 +55,8 @@ api_key_query = APIKeyQuery(
class KeyChecker(SecurityBase):
def __init__(
self,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
api_key: Optional[str] = None,
expected_key_type: Optional[KeyType] = None,
):
self.auto_error: bool = True
self.expected_key_type = expected_key_type
@@ -137,17 +137,17 @@ async def require_invoice_key(
async def check_access_token(
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
cookie_access_token: Annotated[str | None, Cookie()] = None,
bearer_access_token: Annotated[str | None, Depends(http_bearer)] = None,
) -> str | None:
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
) -> Optional[str]:
return header_access_token or cookie_access_token or bearer_access_token
async def check_user_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token, r["path"], r["method"])
@@ -176,9 +176,9 @@ async def check_user_exists(
async def optional_user_id(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
@@ -189,7 +189,7 @@ async def optional_user_id(
async def access_token_payload(
access_token: Annotated[str | None, Depends(check_access_token)],
access_token: Annotated[Optional[str], Depends(check_access_token)],
) -> AccessTokenPayload:
if not access_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
@@ -231,11 +231,11 @@ def parse_filters(model: type[TFilterModel]):
def dependency(
request: Request,
limit: int | None = None,
offset: int | None = None,
sortby: str | None = None,
direction: Literal["asc", "desc"] | None = None,
search: str | None = Query(None, description="Text based search"),
limit: Optional[int] = None,
offset: Optional[int] = None,
sortby: Optional[str] = None,
direction: Optional[Literal["asc", "desc"]] = None,
search: Optional[str] = Query(None, description="Text based search"),
):
params = request.query_params
filters = []
@@ -259,7 +259,7 @@ def parse_filters(model: type[TFilterModel]):
async def check_user_extension_access(
user_id: str, ext_id: str, conn: Connection | None = None
user_id: str, ext_id: str, conn: Optional[Connection] = None
) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
@@ -292,7 +292,7 @@ async def _check_user_extension_access(user_id: str, path: str):
async def _get_account_from_token(
access_token: str, path: str, method: str
) -> Account | None:
) -> Optional[Account]:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return await _get_account_from_jwt_payload(
@@ -310,7 +310,7 @@ async def _get_account_from_token(
async def _get_account_from_jwt_payload(
payload: AccessTokenPayload, path: str, method: str
) -> Account | None:
) -> Optional[Account]:
account = None
if payload.sub:
account = await get_account_by_username(payload.sub)
+2 -1
View File
@@ -1,6 +1,7 @@
import sys
import traceback
from http import HTTPStatus
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
@@ -29,7 +30,7 @@ class UnsupportedError(Exception):
pass
def render_html_error(request: Request, exc: Exception) -> Response | None:
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
+4 -11
View File
@@ -3,8 +3,6 @@ from __future__ import annotations
import importlib
from enum import Enum
from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
@@ -17,7 +15,7 @@ class FiatProviderType(Enum):
stripe = "StripeWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
async def get_fiat_provider(name: str) -> FiatProvider:
if name not in FiatProviderType.__members__:
raise ValueError(f"Fiat provider '{name}' is not supported.")
@@ -29,18 +27,13 @@ async def get_fiat_provider(name: str) -> FiatProvider | None:
del fiat_providers[name]
else:
return fiat_provider
_provider = _init_fiat_provider(FiatProviderType[name])
if not _provider:
return None
fiat_providers[name] = _provider
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
return fiat_providers[name]
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider | None:
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
if not settings.is_fiat_provider_enabled(fiat_provider.name):
logger.warning(f"Fiat provider '{fiat_provider.name}' not enabled.")
return None
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
provider_constructor = getattr(fiat_module, fiat_provider.value)
return provider_constructor()
+1 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
pass
@@ -106,7 +106,6 @@ class FiatProvider(ABC):
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> Coroutine[None, None, FiatInvoiceResponse]:
pass
+65 -225
View File
@@ -2,12 +2,11 @@ import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from typing import Optional
from urllib.parse import urlencode
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
@@ -23,34 +22,6 @@ from .base import (
FiatStatusResponse,
)
FiatMethod = Literal["checkout", "terminal"]
class StripeTerminalOptions(BaseModel):
class Config:
extra = "ignore"
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
class StripeCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class StripeCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None
class StripeWallet(FiatProvider):
"""https://docs.stripe.com/api"""
@@ -60,9 +31,9 @@ class StripeWallet(FiatProvider):
self._settings_fields = self._settings_connection_fields()
if not settings.stripe_api_endpoint:
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
if not settings.stripe_api_secret_key:
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
@@ -78,7 +49,7 @@ class StripeWallet(FiatProvider):
logger.warning(f"Error closing stripe wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
self, only_check_settings: Optional[bool] = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
@@ -90,10 +61,8 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
data = r.json()
available = data.get("available") or []
available_balance = 0
if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0))
available_balance = data.get("available", [{}])[0].get("amount", 0)
# pending_balance = data.get("pending", {}).get("amount", 0)
return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError:
@@ -107,48 +76,82 @@ class StripeWallet(FiatProvider):
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
memo: Optional[str] = None,
**kwargs,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
form_data = [
("mode", "payment"),
(
"success_url",
settings.stripe_payment_success_url or "https://lnbits.com",
),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
("line_items[0][price_data][unit_amount]", amount_cents),
("line_items[0][quantity]", "1"),
]
encoded_data = urlencode(form_data)
if opts.fiat_method == "checkout":
return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts
)
if opts.fiat_method == "terminal":
return await self._create_terminal_invoice(
amount_cents, currency, payment_hash, opts
try:
headers = self.headers.copy()
headers["Content-Type"] = "application/x-www-form-urlencoded"
r = await self.client.post(
url="/v1/checkout/sessions", headers=headers, content=encoded_data
)
r.raise_for_status()
data = r.json()
return FiatInvoiceResponse(
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
)
session_id = data.get("id")
if not session_id:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing session id'"
)
payment_request = data.get("url")
if not payment_request:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing payment URL'"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=payment_request
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Stripe does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
stripe_id = self._normalize_stripe_id(checking_id)
r = await self.client.get(
url=f"/v1/checkout/sessions/{checking_id}",
)
r.raise_for_status()
if stripe_id.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
r.raise_for_status()
return self._status_from_checkout_session(r.json())
data = r.json()
payment_status = data.get("payment_status")
if not payment_status:
return FiatPaymentPendingStatus()
if payment_status == "paid":
# todo: handle fee
return FiatPaymentSuccessStatus()
if stripe_id.startswith("pi_"):
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
r.raise_for_status()
return self._status_from_payment_intent(r.json())
expires_at = data.get("expires_at")
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and expires_at < _24_hours_ago.timestamp():
# be defensive: add a 24 hour buffer
return FiatPaymentFailedStatus()
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
return FiatPaymentPendingStatus()
except Exception as exc:
logger.debug(f"Error getting invoice status: {exc}")
return FiatPaymentPendingStatus()
@@ -165,169 +168,6 @@ class StripeWallet(FiatProvider):
value = await mock_queue.get()
yield value
async def create_terminal_connection_token(self) -> dict:
r = await self.client.post("/v1/terminal/connection_tokens")
r.raise_for_status()
return r.json()
async def _create_checkout_invoice(
self,
amount_cents: int,
currency: str,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
co = opts.checkout or StripeCheckoutOptions()
success_url = (
co.success_url
or settings.stripe_payment_success_url
or "https://lnbits.com"
)
line_item_name = co.line_item_name or memo or "LNbits Invoice"
form_data: list[tuple[str, str]] = [
("mode", "payment"),
("success_url", success_url),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", line_item_name),
("line_items[0][price_data][unit_amount]", str(amount_cents)),
("line_items[0][quantity]", "1"),
]
form_data += self._encode_metadata("metadata", co.metadata)
try:
r = await self.client.post(
"/v1/checkout/sessions",
headers=self._build_headers_form(),
content=urlencode(form_data),
)
r.raise_for_status()
data = r.json()
session_id, url = data.get("id"), data.get("url")
if not session_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing id or url"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=url
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def _create_terminal_invoice(
self,
amount_cents: int,
currency: str,
payment_hash: str,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
term = opts.terminal or StripeTerminalOptions()
data: dict[str, str] = {
"amount": str(amount_cents),
"currency": currency.lower(),
"payment_method_types[]": "card_present",
"capture_method": term.capture_method,
"metadata[payment_hash]": payment_hash,
"metadata[source]": "lnbits",
}
for k, v in (term.metadata or {}).items():
data[f"metadata[{k}]"] = str(v)
try:
r = await self.client.post("/v1/payment_intents", data=data)
r.raise_for_status()
pi = r.json()
pi_id, client_secret = pi.get("id"), pi.get("client_secret")
if not pi_id or not client_secret:
return FiatInvoiceResponse(
ok=False,
error_message="Error: missing PaymentIntent or client_secret",
)
return FiatInvoiceResponse(
ok=True, checking_id=pi_id, payment_request=client_secret
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return (
checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_")
else checking_id
)
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp():
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status")
if status == "succeeded":
return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus()
if status == "requires_payment_method":
if pi.get("last_payment_error"):
return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp()
created_ts = float(pi.get("created") or now_ts)
is_stale = (now_ts - created_ts) > 300
if is_stale:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]:
return {**self.headers, "Content-Type": "application/x-www-form-urlencoded"}
def _encode_metadata(
self, prefix: str, md: dict[str, Any]
) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for k, v in (md or {}).items():
out.append((f"{prefix}[{k}]", str(v)))
return out
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None:
try:
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}")
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
+22 -43
View File
@@ -3,7 +3,7 @@ import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from typing import Any, Optional
from urllib import request
from urllib.parse import urlparse
@@ -16,6 +16,7 @@ from packaging import version
from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
@@ -39,7 +40,7 @@ def urlsafe_short_hash() -> str:
return shortuuid.uuid()
def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str:
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
base = f"http://{settings.host}:{settings.port}" if external else ""
url_params = "?"
for key, value in params.items():
@@ -52,13 +53,8 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = [
"lnbits/templates",
"lnbits/core/templates",
settings.extension_builder_working_dir_path.as_posix(),
]
def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders:
additional_folders += [
Path(settings.lnbits_extensions_path, "extensions", f)
@@ -87,8 +83,8 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_NODE_UI": settings.lnbits_node_ui and get_node_class() is not None,
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
@@ -104,8 +100,11 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
"LNBITS_DENOMINATION": (
settings.lnbits_denomination
if settings.lnbits_denomination == "FakeWallet"
else "sats"
),
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
@@ -216,7 +215,7 @@ def is_valid_pubkey(pubkey: str) -> bool:
return False
def create_access_token(data: dict, token_expire_minutes: int | None = None) -> str:
def create_access_token(data: dict, token_expire_minutes: Optional[int] = None) -> str:
minutes = token_expire_minutes or settings.auth_token_expire_minutes
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
to_encode = {k: v for k, v in data.items() if v is not None}
@@ -224,7 +223,9 @@ def create_access_token(data: dict, token_expire_minutes: int | None = None) ->
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
def encrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
def encrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
"""
Encrypt message with the internal secret key
@@ -237,7 +238,9 @@ def encrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
def decrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
def decrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
"""
Decrypt message with the internal secret key
@@ -250,7 +253,7 @@ def decrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
def filter_dict_keys(data: dict, filter_keys: list[str] | None) -> dict:
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
@@ -271,7 +274,7 @@ def version_parse(v: str):
def is_lnbits_version_ok(
min_lnbits_version: str | None, max_lnbits_version: str | None
min_lnbits_version: Optional[str], max_lnbits_version: Optional[str]
) -> bool:
if min_lnbits_version and (
version_parse(min_lnbits_version) > version_parse(settings.version)
@@ -348,7 +351,7 @@ def path_segments(path: str) -> list[str]:
return segments[0:]
def normalize_path(path: str | None) -> str:
def normalize_path(path: Optional[str]) -> str:
path = path or ""
return "/" + "/".join(path_segments(path))
@@ -373,27 +376,3 @@ def normalize_endpoint(endpoint: str, add_proto=True) -> str:
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
)
return endpoint
def camel_to_words(name: str) -> str:
# Add space before capital letters (but not at the start)
words = re.sub(r"(?<!^)(?=[A-Z])", " ", name)
return words.strip()
def camel_to_snake(name: str) -> str:
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
return name.lower()
def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
def is_snake_case(v: str) -> bool:
return re.match(r"^[a-z]+(_[a-z0-9]+)*$", v) is not None
def lowercase_first_letter(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
+66
View File
@@ -0,0 +1,66 @@
from http import HTTPStatus
from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=HTTPStatus.OK,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
"decode",
"encode",
"handle",
]
+7 -7
View File
@@ -2,7 +2,7 @@ import asyncio
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
from typing import Any, Optional, Union
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -62,7 +62,7 @@ class InstalledExtensionMiddleware:
def _response_by_accepted_type(
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
) -> HTMLResponse | JSONResponse:
) -> Union[HTMLResponse, JSONResponse]:
"""
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
as HTTP code. If the `accept` HTTP header is present int the request and
@@ -127,7 +127,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
start_time = datetime.now(timezone.utc)
request_details = await self._request_details(request)
response: Response | None = None
response: Optional[Response] = None
try:
response = await call_next(request)
@@ -140,13 +140,13 @@ class AuditMiddleware(BaseHTTPMiddleware):
async def _log_audit(
self,
request: Request,
response: Response | None,
response: Optional[Response],
duration: float,
request_details: str | None,
request_details: Optional[str],
):
try:
http_method = request.scope.get("method", None)
path: str | None = getattr(request.scope.get("route", {}), "path", None)
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
response_code = str(response.status_code) if response else None
if not settings.audit_http_request(http_method, path, response_code):
return None
@@ -178,7 +178,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
except Exception as ex:
logger.warning(ex)
async def _request_details(self, request: Request) -> str | None:
async def _request_details(self, request: Request) -> Optional[str]:
if not settings.audit_http_request_details():
return None
+15
View File
@@ -0,0 +1,15 @@
from typing import Optional
from .base import Node
def get_node_class() -> Optional[Node]:
return NODE
def set_node_class(node: Node):
global NODE
NODE = node
NODE: Optional[Node] = None
+1 -1
View File
@@ -11,12 +11,12 @@ from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
+1 -1
View File
@@ -35,7 +35,7 @@ def main(
ssl_certfile: str,
reload: bool,
):
"""Launched with `uv run lnbits` at root level"""
"""Launched with `poetry run lnbits` at root level"""
# create data dir if it does not exist
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
+8 -31
View File
@@ -51,19 +51,11 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_manifests: list[str] = Field(
default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
]
)
lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
)
@property
def extension_builder_working_dir_path(self) -> Path:
return Path(settings.lnbits_data_folder, "extensions_builder")
class ExtensionsInstallSettings(LNbitsSettings):
@@ -150,14 +142,14 @@ class ExchangeRateProvider(BaseModel):
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=set())
lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_installed_extensions_ids: set[str] = Field(default=set())
lnbits_installed_extensions_ids: set[str] = Field(default=[])
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
@@ -274,7 +266,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str | None = Field(default=None)
lnbits_default_bgimage: str = Field(default=None)
class OpsSettings(LNbitsSettings):
@@ -440,18 +432,6 @@ class NotificationsSettings(LNbitsSettings):
default=1_000_000, ge=0
)
def is_nostr_notifications_configured(self) -> bool:
return (
self.lnbits_nostr_notifications_enabled
and self.lnbits_nostr_notifications_private_key is not None
)
def is_telegram_notifications_configured(self) -> bool:
return (
self.lnbits_telegram_notifications_enabled
and self.lnbits_telegram_notifications_access_token is not None
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -737,7 +717,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods: list[str] = [a.value for a in AuthMethods]
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
AuthMethods.user_id_only.value,
@@ -955,7 +935,7 @@ class EnvSettings(LNbitsSettings):
class PersistenceSettings(LNbitsSettings):
lnbits_data_folder: str = Field(default="./data")
lnbits_database_url: str | None = Field(default=None)
lnbits_database_url: str = Field(default=None)
class SuperUserSettings(LNbitsSettings):
@@ -1001,14 +981,11 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_running: bool = Field(default=True)
# Remember the latest balance delta in order to compare with the current one
latest_balance_delta_sats: int = Field(default=0)
latest_balance_delta_sats: int = Field(default=None)
lnbits_all_extensions_ids: set[str] = Field(default=set())
lnbits_all_extensions_ids: set[str] = Field(default=[])
server_startup_time: int = Field(default=int(time()))
has_holdinvoice: bool = Field(default=False)
has_nodemanager: bool = Field(default=False)
server_startup_time: int = Field(default=time())
@property
def lnbits_server_up_time(self) -> str:
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
File diff suppressed because one or more lines are too long
+4 -29
View File
@@ -62,14 +62,6 @@ body[data-theme=monochrome].neon-border .q-date--dark {
box-shadow: none;
}
body[data-theme=salvador].neon-border .q-card,
body[data-theme=salvador].neon-border .q-card.q-card--dark,
body[data-theme=salvador].neon-border .q-date,
body[data-theme=salvador].neon-border .q-date--dark {
border: 2px solid #1976d2;
box-shadow: none;
}
body.hard-border .q-card,
body.hard-border .q-card.q-card--dark,
body.hard-border .q-date,
@@ -117,21 +109,21 @@ body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
body[data-theme=freedom] {
--q-primary: #e22156;
--q-secondary: #b91a45;
--q-dark-page: #462f36;
--q-dark-page: #0a0a0a;
}
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
background: #47393d !important;
background: #1b1b1b !important;
}
body[data-theme=cyber] {
--q-primary: #7cb342;
--q-secondary: #558b2f;
--q-dark-page: #000;
--q-dark-page: #0a0a0a;
}
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
background: #1f2915 !important;
background: #1b1b1b !important;
}
body[data-theme=mint] {
@@ -174,16 +166,6 @@ body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
background: rgb(39, 39, 39) !important;
}
body[data-theme=salvador] {
--q-primary: #1976d2;
--q-secondary: #26a69a;
--q-dark-page: #253647;
}
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
background: #343d47 !important;
}
body.gradient-bg {
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
background-attachment: fixed;
@@ -243,13 +225,6 @@ body.body--dark .q-field--error .q-field__messages {
width: 500px;
}
.blur-and-disable {
filter: blur(4px); /* blur entire element & children */
pointer-events: none; /* block mouse interaction */
user-select: none; /* prevent text selection */
opacity: 0.6; /* optional: faded look */
}
.lnbits__table-bordered td,
.lnbits__table-bordered th {
border: 1px solid black;
+4 -37
View File
@@ -49,11 +49,9 @@ window.localisation.en = {
export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
stored_paylinks: 'Stored LNURL pay links',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
@@ -115,7 +113,6 @@ window.localisation.en = {
payment_check: 'Check payment',
not_enough_funds: 'Not enough funds!',
search_by_tag_memo_amount: 'Search by tag, memo, amount',
search: 'Search',
invoice_waiting: 'Invoice waiting to be paid',
payment_received: 'Payment Received',
payment_sent: 'Payment Sent',
@@ -164,12 +161,8 @@ window.localisation.en = {
featured: 'Featured',
all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
new_version: 'New Version',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:',
extension_rating_soon: 'Ratings coming soon',
extension_installed_version: 'Installed version',
@@ -183,17 +176,7 @@ window.localisation.en = {
extension_required_lnbits_version: 'This release requires LNbits version',
min_version: 'Minimum (included)',
max_version: 'Maximum (excluded)',
preimage: 'Preimage',
preimage_hint: 'Preimage to settle the hold invoice',
hold_invoice: 'Hold Invoice',
hold_invoice_description:
'This invoice is on hold and requires a preimage to settle.',
payment_hash: 'Payment Hash',
invoice_cancelled: 'Invoice Cancelled',
invoice_settled: 'Invoice Settled',
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
settle_invoice: 'Settle Invoice',
cancel_invoice: 'Cancel Invoice',
fee: 'Fee',
amount: 'Amount',
amount_limits: 'Amount Limits',
@@ -236,9 +219,6 @@ window.localisation.en = {
notifications_nostr_private_key: 'Nostr Private Key',
notifications_nostr_private_key_desc:
'Private key (hex or nsec) to sign the messages sent to Nostr',
notifications_nostr_identifier: 'Nostr Identifier',
notifications_nostr_identifier_desc:
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to',
@@ -248,10 +228,9 @@ window.localisation.en = {
notifications_enable_telegram_desc: 'Send notfications over Telegram',
notifications_telegram_access_token: 'Access Token',
notifications_telegram_access_token_desc: 'Access token for the bot',
notifications_chat_id: 'Telegram Chat ID',
notifications_chat_id_desc: 'Telegram Chat ID to send the notifications to',
notifications_excluded_wallets_desc:
'Do not send notifications for these wallets',
notifications_chat_id: 'Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to',
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notifications over email',
@@ -424,7 +403,6 @@ window.localisation.en = {
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}',
@@ -494,16 +472,9 @@ window.localisation.en = {
user_default_extensions_label: 'User extensions',
user_default_extensions_hint:
'Extensions that will be enabled by default for the users.',
extension_builder: 'Extension Builder',
extension_builder_manifest_url: 'Extension Builder Manifest URL',
extension_builder_manifest_url_hint:
'URL to a JSON manifest file with extension builder details',
miscellanous: 'Miscellanous',
misc_disable_extensions: 'Disable Extensions',
misc_disable_extensions_label: 'Disable all extensions',
misc_disable_extensions_builder: 'Enable Extensions Builder',
misc_disable_extensions_builder_label:
'Enable Extensions Builder for non admin users.',
misc_hide_api: 'Hide API',
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
wallets_management: 'Wallets Management',
@@ -663,9 +634,5 @@ window.localisation.en = {
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
connected: 'Connected',
not_connected: 'Not Connected',
free: 'Free',
paid: 'Paid'
'The user will be redirected to this URL after the payment is successful'
}
+7 -64
View File
@@ -22,7 +22,6 @@ window.localisation.fi = {
active_channels: 'Aktiivisia kanavia',
connect_peer: 'Yhdistä naapuriin',
connect: 'Yhdistä',
reconnect: 'Uudista yhteys',
open_channel: 'Avaa kanava',
open: 'Avaa',
close_channel: 'Sulje kanava',
@@ -52,18 +51,14 @@ window.localisation.fi = {
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voit lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
access_wallet_on_mobile: 'Mobiili käyttö',
wallet: 'Lompakko:',
wallet_name: 'Lompakon nimi',
wallets: 'Lompakot',
add_wallet: 'Lisää lompakko',
add_new_wallet: 'Lisää uusi lompakko',
pin_wallet: 'Kiinnitä lompakko',
delete_wallet: 'Poista lompakko',
delete_wallet_desc:
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
rename_wallet: 'Nimeä lompakko uudelleen',
update_name: 'Tallenna',
fiat_tracking: 'Käytettävä valuutta',
fiat_providers: 'Valuutan välittäjät',
currency: 'Valuutta',
update_currency: 'Tallenna',
press_to_claim: 'Lunasta varat painamalla tästä',
@@ -127,7 +122,6 @@ window.localisation.fi = {
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
search_extensions: 'Etsi laajennuksia',
search_wallets: 'Etsi lompakkoa',
extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label:
@@ -140,7 +134,6 @@ window.localisation.fi = {
uninstall: 'Poista',
drop_db: 'Poista tiedot',
enable: 'Ota käyttöön',
enabled: 'Käytössä',
pay_to_enable: 'Maksa ottaaksesi käyttöön',
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
disable: 'Poista käytöstä',
@@ -172,33 +165,12 @@ window.localisation.fi = {
payment_hash: 'Maksun tiiviste',
fee: 'Kulu',
amount: 'Määrä',
amount_limits: 'Määrien rajat',
amount_sats: 'Määrä (sat)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanhenee',
webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Maksun varmenne',
update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!',
@@ -290,7 +262,6 @@ window.localisation.fi = {
notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'näytä lisää',
more_count: 'näytä {count} lisää',
less: 'supista',
releases: 'Julkaisut',
watchdog: 'Watchdog',
@@ -305,7 +276,7 @@ window.localisation.fi = {
callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint:
'Callback URL:it (kuten LNURL) tarkistetaan kaikkien näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
'Callback URL:it (kuten LNURL) tarkistetaan näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
wallet_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
@@ -330,9 +301,6 @@ window.localisation.fi = {
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
delete_all_wallets: 'Poista kaikki lompakot',
confirm_delete_all_wallets:
'Oletko todellakin varma, että haluat poistaa käyttäjältä KAIKKI lompakot?',
login_to_account: 'Kirjaudu käyttäjänimellä',
create_account: 'Luo tili',
account_settings: 'Tilin asetukset',
@@ -341,7 +309,7 @@ window.localisation.fi = {
signin_with_nostr: 'Kirjaudu Nostr:lla',
signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_custom_org: 'Kirjaudu {custom_org}-palvelulla',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
@@ -350,10 +318,7 @@ window.localisation.fi = {
change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain',
nostr_pubkey_tooltip:
'Syötä tämän käyttäjän julkinen Nostr avain (hex arvona)',
set_password: 'Aseta salasana',
set_password_tooltip: 'Aseta käyttäjätunnukselle salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
invalid_password_repeat: 'Salasanat eivät täsmää',
reset_key_generated: 'Salasanan vaihtoavain on luotu.',
@@ -362,8 +327,7 @@ window.localisation.fi = {
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
pubkey: 'Julkinen avain',
user_id: 'Käyttäjä tunnus',
id: 'tunnus',
user_id: 'Käyttäjä ID',
email: 'Sähköposti',
first_name: 'Etunimi',
last_name: 'Sukunimi',
@@ -379,8 +343,6 @@ window.localisation.fi = {
back: 'Takaisin',
logout: 'Poistu',
look_and_feel: 'Kieli ja värit',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token',
api_tokens: 'API Tokens',
access_control_list: 'Access Control List',
@@ -392,14 +354,11 @@ window.localisation.fi = {
gradient_background: 'Gradient Background',
language: 'Kieli',
color_scheme: 'Väriteema',
visible_wallet_count: 'Näytettävien lompakkojen määrä',
admin_settings: 'Pääkäyttäjän asetukset',
extension_cost: 'Tämä laajennus edellyttää vähintään {cost} sat maksua.',
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta',
pay_with: 'Maksa {provider}:lla',
select_payment_provider: 'Valitse maksun välittäjä',
wallet_required: 'Lompakko *',
show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen',
@@ -412,9 +371,6 @@ window.localisation.fi = {
'{name} -laajennuksen aktivointi edellyttää vähintään {amount} sat maksua.',
hide_empty_wallets: 'Piilota tyhjät lompakot',
recheck: 'Tarkista uudelleen',
check: 'Tarkista',
check_connection: 'Tarkista yhteys',
check_webhook: 'Tarkista Webhook',
contributors: 'Avustajat',
license: 'Lisenssi',
reset_key: 'Vaihda avain',
@@ -491,9 +447,9 @@ window.localisation.fi = {
fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika (sekuntia)',
payment_wait_time: 'Maksun odotusaika',
payment_wait_time_desc:
'Kuinka pitkään maksua odotetaan saapuvaksi, ennen kuin se merkitään Odotetaan-tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua, tms',
'Kuinka pitkään maksua odotetaan saapuvaksi ennenkuin se merkitään Odotetaan -tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua tms',
server_management: 'Palvelimen hallinta',
base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL',
@@ -518,16 +474,12 @@ window.localisation.fi = {
auth_keycloak_ci_hint:
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
min_incoming_payment_amount: 'Pienin vastaanotettava maksun määrä',
min_incoming_payment_amount_desc:
'Pienin maksun määrä jolle voi luoda laskun',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa',
max_outgoing_payment_amount: 'Lähtevän maksun enimmäismäärä',
@@ -590,8 +542,6 @@ window.localisation.fi = {
admin_users_label: 'Käyttäjätunnus',
allowed_users: 'Sallitut käyttäjät',
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_hint_feature:
'Ainoastaan nämä käyttäjät voivat käyttää ominaisuutta {feature}',
allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
@@ -627,12 +577,5 @@ window.localisation.fi = {
view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
websocket_example: 'Websocket esimerkki'
}
-6
View File
@@ -80,11 +80,6 @@ window.AccountPageLogic = {
token_id_list: [],
allRead: false,
allWrite: false
},
notifications: {
nostr: {
identifier: ''
}
}
}
},
@@ -401,7 +396,6 @@ window.AccountPageLogic = {
}
}
},
async created() {
try {
const {data} = await LNbits.api.getAuthenticatedUser()
+1 -4
View File
@@ -468,10 +468,7 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError)
},
formatDate(date) {
return moment
.utc(date * 1000)
.local()
.fromNow()
return moment.utc(date * 1000).fromNow()
},
sendTestEmail() {
LNbits.api
+40 -19
View File
@@ -1,15 +1,14 @@
window.LNbits = {
g: window.g,
api: {
request(method, url, apiKey, data, options = {}) {
request(method, url, apiKey, data) {
return axios({
method: method,
url: url,
headers: {
'X-Api-Key': apiKey
},
data: data,
...options
data: data
})
},
getServerHealth() {
@@ -20,19 +19,17 @@ window.LNbits = {
amount,
memo,
unit = 'sat',
lnurlWithdraw = null,
lnurlCallback = null,
fiatProvider = null,
internalMemo = null,
payment_hash = null
internalMemo = null
) {
const data = {
out: false,
amount: amount,
memo: memo,
unit: unit,
lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider,
payment_hash: payment_hash
lnurl_callback: lnurlCallback,
fiat_provider: fiatProvider
}
if (internalMemo) {
data.extra = {
@@ -53,14 +50,39 @@ window.LNbits = {
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
payLnurl(
wallet,
callback,
description_hash,
amount,
description = '',
comment = '',
unit = '',
internalMemo = null
) {
const data = {
callback,
description_hash,
amount,
comment,
description,
unit
}
if (internalMemo) {
data.internal_memo = String(internalMemo)
}
return this.request(
'post',
'/api/v1/payments/lnurl',
wallet.adminkey,
data
)
},
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
})
},
createAccount(name) {
@@ -267,10 +289,10 @@ window.LNbits = {
}
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.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
@@ -616,7 +638,6 @@ window.windowMixin = {
}
},
applyBackgroundImage() {
if (this.bgimageChoice == 'null') this.bgimageChoice = ''
if (this.bgimageChoice == '') {
document.body.classList.remove('bg-image')
} else {
+28
View File
@@ -187,6 +187,34 @@ window.app.component('lnbits-lnurlpay-success-action', {
}
})
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
-115
View File
@@ -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
}
)
}
}
})

Some files were not shown because too many files have changed in this diff Show More