Compare commits

..
298 changed files with 24995 additions and 40751 deletions
-7
View File
@@ -23,10 +23,3 @@ mypy.ini
package-lock.json package-lock.json
package.json package.json
pytest.ini pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
+6 -18
View File
@@ -6,10 +6,6 @@
# The following settings are ONLY set in your .env file. # 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. # They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Logging and Development === # === Logging and Development ===
DEBUG=False DEBUG=False
@@ -22,6 +18,7 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file # https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB" LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
@@ -59,7 +56,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx # LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui # 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, SparkWallet, SparkL2Wallet" # 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 # uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers # IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -190,15 +187,7 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon" BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# SparkL2Wallet (external sidecar: https://github.com/lnbits/spark_sidecar)
SPARK_L2_NETWORK=MAINNET
SPARK_L2_EXTERNAL_ENDPOINT=http://127.0.0.1:8765
SPARK_L2_EXTERNAL_API_KEY=
# optional tuning
# SPARK_L2_PAY_WAIT_MS=4000
# SPARK_L2_PAY_POLL_MS=500
# SPARK_L2_STREAM_KEEPALIVE_MS=15000
# StrikeWallet # StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1 STRIKE_API_ENDPOINT=https://api.strike.me/v1
@@ -326,12 +315,10 @@ LNBITS_SERVICE_FEE=0.0
# disable fees for internal transactions # disable fees for internal transactions
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true # LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
# The minimum fee reserved per payment (millisats) # value in millisats
LNBITS_RESERVE_FEE_MIN=2000 LNBITS_RESERVE_FEE_MIN=2000
# The percentage of the payment amount to reserve for routing fees (percent) # value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0 LNBITS_RESERVE_FEE_PERCENT=1.0
# The default time to wait to for status response from the funding source when paying an invoice
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
# limit the maximum balance for each wallet # limit the maximum balance for each wallet
# throw an error if the wallet attempts to create a new invoice # throw an error if the wallet attempts to create a new invoice
@@ -346,3 +333,4 @@ LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
###################################### ######################################
###### Logging and Development ####### ###### Logging and Development #######
###################################### ######################################
+1 -8
View File
@@ -75,14 +75,7 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
backend-wallet-class: backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with: with:
custom-pytest: "uv run pytest tests/regtest" custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
+2 -13
View File
@@ -55,21 +55,10 @@ jobs:
- name: Build and push boltz - name: Build and push boltz
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: docker/boltzclient context: .
file: Dockerfile.boltz
push: true push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }} tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
- name: Build and push sparkl2
uses: docker/build-push-action@v5
with:
context: docker/sparkl2
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-sparkl2:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+11 -12
View File
@@ -38,9 +38,10 @@ jobs:
- name: Setup Regtest - name: Setup Regtest
run: | run: |
cd docker/regtest git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
chmod +x ./start-regtest cd docker
./start-regtest chmod +x ./tests
./tests
sudo chmod -R a+rwx . sudo chmod -R a+rwx .
- name: Run pytest - name: Run pytest
@@ -49,21 +50,19 @@ jobs:
LNBITS_DATABASE_URL: ${{ inputs.db-url }} LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }} LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
LND_REST_ENDPOINT: https://localhost:8081/ LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: ./docker/regtest/data/lnd-3/tls.cert LND_REST_CERT: ./docker/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_GRPC_ENDPOINT: localhost LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009 LND_GRPC_PORT: 10009
LND_GRPC_CERT: ./docker/regtest/data/lnd-3/tls.cert LND_GRPC_CERT: docker/data/lnd-3/tls.cert
LND_GRPC_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/regtest/data/clightning-1/regtest/lightning-rpc CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
CORELIGHTNING_REST_URL: https://localhost:3001 CORELIGHTNING_REST_URL: https://localhost:3001
CORELIGHTNING_REST_MACAROON: ./docker/regtest/data/clightning-2-rest/access.macaroon CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/regtest/data/clightning-2-rest/certificate.pem CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
LNBITS_ENDPOINT: http://localhost:5001 LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee" LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082 ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
+7 -52
View File
@@ -10,30 +10,7 @@ permissions:
jobs: jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
@@ -41,37 +18,15 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest-rc:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest-rc
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.10 - name: Build and publish to pypi
uses: actions/setup-python@v5 uses: JRubics/poetry-publish@v1.15
with: with:
python-version: "3.10" pypi_token: ${{ secrets.PYPI_API_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+9 -33
View File
@@ -14,7 +14,7 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@@ -22,18 +22,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT" echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -43,7 +35,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest: docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -52,33 +43,18 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest-rc:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest-rc
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.10 - name: Build and publish to pypi
uses: actions/setup-python@v5 uses: JRubics/poetry-publish@v1.15
with: with:
python-version: "3.10" pypi_token: ${{ secrets.PYPI_API_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage: appimage:
needs: [ release ] needs: [ release ]
+1
View File
@@ -42,6 +42,7 @@ lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old lnbits/static/bundle-components.min.js.old
lnbits/upgrades lnbits/upgrades
docker
# Nix # Nix
*result* *result*
+1 -1
View File
@@ -23,7 +23,7 @@ repos:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: v3.7.4 rev: v3.6.2
hooks: hooks:
- id: prettier - id: prettier
types_or: [css, javascript, html, json] types_or: [css, javascript, html, json]
@@ -1,7 +1,6 @@
ARG LNBITS_TAG=latest
FROM boltz/boltz-client:latest AS boltz FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/ COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd RUN ls -l /usr/local/bin/boltzd
@@ -14,13 +13,11 @@ ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage) # Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras RUN uv sync --all-extras
# LNbits # LNbits + boltzd configuration
ENV LNBITS_PORT="5000" ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0" ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet" ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES="10" ENV FUNDING_SOURCE_MAX_RETRIES=10
# Boltzd
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002" ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon" ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert" ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
@@ -28,7 +25,8 @@ ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000 EXPOSE 5000
COPY entrypoint.sh /entrypoint.sh # Entrypoint to start boltzd and LNbits
RUN chmod +x /entrypoint.sh COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/entrypoint.sh"] CMD ["/dockerboltz.sh"]
+5 -4
View File
@@ -5,9 +5,8 @@
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" /> <img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -78,11 +77,13 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) [![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
[docs]: https://github.com/lnbits/lnbits/wiki [docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
-18
View File
@@ -1,18 +0,0 @@
!data
data/*
!data/boltz
data/boltz/*
!data/boltz/boltz.conf
!data/boltz-client
data/boltz-client/*
!data/boltz-client/boltz.toml
!data/boltz-nginx
data/boltz-nginx/*
!data/boltz-nginx/default.conf
!data/eclair
data/eclair/*
!data/eclair/eclair.conf
-99
View File
@@ -1,99 +0,0 @@
![TESTS](https://github.com/lnbits/legend-regtest-enviroment/actions/workflows/ci.yml/badge.svg)
# nodes
- lnd-1: for locally testing your current lnbits
- lnd-2: used for boltz backend
- lnd-3: used for lnbits inside docker
- cln-1: for locally testing your current lnbits
- cln-2: used for clightning-REST
- eclair-1: for locally testing your current lnbits
# Installing regtest
get the regtest enviroment ready
```sh
# Install docker https://docs.docker.com/engine/install/
# Make sure your user has permission to use docker 'sudo usermod -aG docker ${USER}' then reboot
# Stop/start docker 'sudo systemctl stop docker' 'sudo systemctl start docker'
sudo apt install jq
git clone https://github.com/lnbits/lnbits.git
cd lnbits
docker build -t lnbits/lnbits .
mkdir docker
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./start-regtest
./start-regtest # start the regtest and also run tests
sudo chown -R $USER ./data # Give the data file permissions for user
```
# Running LNbits on regtest
add this ENV variables to your `.env` file
```sh
DEBUG=true
# LND
LNBITS_BACKEND_WALLET_CLASS="LndRestWallet"
LND_REST_ENDPOINT=https://127.0.0.1:8081/
LND_REST_CERT=/home/user/repos/lnbits/docker/data/lnd-1/tls.cert
LND_REST_MACAROON=/home/user/repos/lnbits/docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
# CLN
LNBITS_BACKEND_WALLET_CLASS="CoreLightningWallet"
CORELIGHTNING_RPC=./docker/data/clightning-1/regtest/lightning-rpc
# Run LNbits
uv run lnbits
# Run LNbits with hot reload
make dev
```
# testing
```sh
chmod +x ./start-regtest
./start-regtest
# short answer :)
./start-regtest && echo "PASSED" || echo "FAILED" > /dev/null
```
usage of the `bitcoin-cli-sim`, `lightning-cli-sim` and `lncli-sim` aliases
```sh
cd ~/lnbits/docker
source docker-scripts.sh
# use bitcoin core, mine a block
bitcoin-cli-sim -generate 1
# use c-lightning nodes
lightning-cli-sim 1 newaddr | jq -r '.bech32' # use node 1
lightning-cli-sim 2 getinfo # use node 2
lightning-cli-sim 3 getinfo # use node 3
# use lnd nodes
lncli-sim 1 newaddr p2wsh
lncli-sim 2 listpeers
```
# urls
- mempool: http://localhost:8080/
- boltz api: http://localhost:9001/
- lnd-1 rest: http://localhost:8081/
- lnbits: http://localhost:5001/
# debugging docker logs
```sh
docker logs lnbits-lnbits-1 -f
docker logs lnbits-boltz-1 -f
docker logs lnbits-clightning-1-1 -f
docker logs lnbits-lnd-2-1 -f
```
@@ -1,49 +0,0 @@
standalone = true
network = "regtest"
# Path the the log file
logfile = ""
electrumUrl = "electrs:19001"
electrumLiquidUrl = "electrs-liquid:19002"
[BOLTZ]
# By default the daemon automatically connects to the official Boltz instance for the network LND is on
# This value is used to override that
url = "http://boltz-nginx:9001"
[DATABASE]
# Path to the SQLite database file
# path = "/home/michael/test.db"
[RPC]
# Host of
host = "0.0.0.0"
# Port of the gRPC interface
port = 9002
# Whether the REST proxy for the gRPC interface should be disabled
restDisabled = false
# Host of the REST proxy
restHost = "0.0.0.0"
# Port of the REST proxy
restPort = 9003
# Path to the TLS cert for the gRPC and REST interface
tlsCert = ""
# Path to the TLS private key for the gRPC and REST interface
tlsKey = ""
noTls = true
# Whether the macaroon authentication for the gRPC and REST interface should be disabled
noMacaroons = true
# Path to the admin macaroon for the gRPC and REST interface
adminMacaroonPath = ""
# Path to the read only macaroon for the gRPC and REST interface
readOnlyMacaroonPath = ""
@@ -1,49 +0,0 @@
upstream boltz {
server boltz:9001;
}
upstream boltzr {
server boltz:9005;
}
upstream ws {
server boltz:9004;
}
server {
listen 9001;
listen [::]:9001;
server_name localhost;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods 'GET, PATCH, DELETE, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers "*" always;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
if ($request_method = OPTIONS) {
return 204;
}
location /v2/ws {
proxy_pass http://ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~ ^/v2/(lightning|swap/rescue|swap/restore) {
proxy_pass http://boltzr;
}
location /streamswapstatus {
proxy_pass http://boltzr;
}
location / {
proxy_pass http://boltz;
}
}
-114
View File
@@ -1,114 +0,0 @@
[api]
host = "0.0.0.0"
port = 9_001
[grpc]
host = "0.0.0.0"
port = 9_000
[postgres]
host = "boltz-postgres"
port = 5432
database = "boltz"
username = "boltz"
password = "boltz"
[sidecar]
[sidecar.grpc]
host = "127.0.0.1"
port = 9003
[sidecar.ws]
host = "0.0.0.0"
port = 9004
[sidecar.api]
host = "0.0.0.0"
port = 9005
[swap]
deferredClaimSymbols = ["BTC", "L-BTC"]
[[pairs]]
base = "BTC"
quote = "BTC"
rate = 1
fee = 0.5
swapInFee = 0.1
maxSwapAmount = 40_294_967
minSwapAmount = 50_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[pairs]]
base = "L-BTC"
quote = "BTC"
fee = 0.25
swapInFee = 0.1
rate = 1
maxSwapAmount = 40_294_967
minSwapAmount = 100
[pairs.submarineSwap]
minSwapAmount = 1_000
minBatchedAmount = 21
[pairs.chainSwap]
minSwapAmount = 25_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[currencies]]
symbol = "BTC"
network = "bitcoinRegtest"
minWalletBalance = 10_000_000
minChannelBalance = 10_000_000
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 0
[currencies.chain]
# mempoolSpace = "http://mempool-web:8090/api"
host = "bitcoind"
zmqpubrawtx = "tcp://bitcoind:29000"
zmqpubrawblock = "tcp://bitcoind:29001"
port = 18_443
cookie = "/root/.bitcoin/regtest/.cookie"
feeFloor = 0.2
wallet = "lnbits"
[currencies.lnd]
host = "lnd-2"
port = 10_009
certpath = "/data/lnd/tls.cert"
macaroonpath = "/data/lnd/data/chain/bitcoin/regtest/admin.macaroon"
[liquid]
symbol = "L-BTC"
network = "liquidRegtest"
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 40_294_967
[liquid.chain]
host = "elementsd"
port = 18884
cookie = "/root/.elements/liquidregtest/.cookie"
zmqpubrawtx = "tcp://elementsd:31000"
zmqpubhashblock = "tcp://elementsd:31002"
wallet = "lnbits"
-28
View File
@@ -1,28 +0,0 @@
eclair {
chain = "regtest"
api {
binding-ip = "0.0.0.0"
enabled = true
port = 8080
password = "lnbits"
}
bitcoind {
host = "bitcoind"
rpcport = 18443
auth = "safecookie"
cookie = "/root/.bitcoin/regtest/.cookie"
zmqblock = "tcp://bitcoind:29002"
zmqtx = "tcp://bitcoind:29000"
}
channel {
max-funding-satoshis = 10000000000
}
features {
option_support_large_channel = mandatory
}
}
-434
View File
@@ -1,434 +0,0 @@
services:
lnbits:
hostname: lnbits
depends_on:
- lnd-3
image: lnbits/lnbits
restart: on-failure
user: "0:0"
environment:
LNBITS_PORT: 5001
DEBUG: true
LNBITS_ADMIN_UI: false
LNBITS_BACKEND_WALLET_CLASS: "LndRestWallet"
LNBITS_DATA_FOLDER: "./data"
LND_REST_ENDPOINT: "https://lnd-3:8081/"
LND_REST_CERT: "./lnd/tls.cert"
LND_REST_MACAROON: "./lnd/data/chain/bitcoin/regtest/admin.macaroon"
ports:
- 5001:5001
volumes:
- lnbits-data:/app/data
- ./data/lnd-3:/app/lnd:uid=1000,gid=1000
boltz:
hostname: boltz
depends_on:
- lnd-2
- boltz-postgres
restart: always
image: boltz/boltz:v3.12.1
ports:
- 9000:9000
entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'"
volumes:
- ./data/lnd-2:/data/lnd/
- ./data/boltz/:/root/.boltz/
- elements-data:/root/.elements
- bitcoin-data:/root/.bitcoin
boltz-client:
hostname: boltz-client
depends_on:
- boltz
restart: always
image: boltz/boltz-client:latest
ports:
- 9002:9002
- 9003:9003
expose:
- 9002
healthcheck:
test: ['CMD', 'boltzcli', '--host', 'boltz-client', 'getinfo']
interval: 5s
timeout: 3s
retries: 10
start_period: 0s
volumes:
- elements-data:/root/.elements
- ./data/boltz-client:/root/.boltz
boltz-backend-nginx:
hostname: boltz-nginx
restart: always
image: nginx:latest
ports:
- 9001:9001
volumes:
- nginx-data:/etc/nginx/conf.d
healthcheck:
test: ['CMD-SHELL', 'curl http://localhost:9001/version']
timeout: 1s
retries: 10
interval: 1s
start_period: 0s
boltz-postgres:
hostname: boltz-postgres
restart: always
image: postgres:14-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready --dbname boltz --username boltz"]
interval: 5s
timeout: 30s
retries: 10
start_period: 5s
environment:
- POSTGRES_DB=boltz
- POSTGRES_USER=boltz
- POSTGRES_PASSWORD=boltz
expose:
- 5432
bitcoind:
hostname: bitcoind
image: boltz/bitcoin-core:25.0
command:
- -regtest
- -fallbackfee=0.00000253
- -zmqpubrawtx=tcp://0.0.0.0:29000
- -zmqpubrawblock=tcp://0.0.0.0:29001
- -zmqpubhashblock=tcp://0.0.0.0:29002
- -txindex
- -rpcallowip=0.0.0.0/0
- -rpcbind=0.0.0.0
- -addresstype=bech32
- -changetype=bech32
- -dbcache=2048
- -rpcworkqueue=256
volumes:
- bitcoin-data:/root/.bitcoin
expose:
- 29000
- 29001
- 29002
- 18443
- 18444
healthcheck:
test:
[
"CMD",
"bitcoin-cli",
"--rpccookiefile=/root/.bitcoin/regtest/.cookie",
"-regtest",
"getblockchaininfo",
]
timeout: 1s
retries: 1
interval: 1s
start_period: 0s
clightning-1:
hostname: clightning-1
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9736
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
- --clnrest-host=0.0.0.0
- --clnrest-port=3010
expose:
- 9735
ports:
- 9736:9736
- 3010:3010
volumes:
- ./data/clightning-1:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2:
hostname: clightning-2
depends_on:
- bitcoind
image: boltz/c-lightning:22.11.1
command:
- --large-channels
- --network=regtest
- --grpc-port=9737
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9737:9737
volumes:
- ./data/clightning-2:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2-rest:
hostname: clightning-2-rest
depends_on:
- clightning-2
image: saubyk/c-lightning-rest:0.10.7
entrypoint: "sh -c 'sleep 35 && /sbin/tini -g -- ./docker-entrypoint.sh'"
ports:
- 3001:3001
expose:
- 3001
volumes:
- ./data/clightning-2:/root/.lightning/:uid=1000,gid=1000
- ./data/clightning-2-rest:/usr/src/app/certs/
- bitcoin-data:/root/.bitcoin
clightning-3:
hostname: clightning-3
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9738
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9738:9738
volumes:
- ./data/clightning-3:/root/.lightning/
- bitcoin-data:/root/.bitcoin
lnd-1:
hostname: lnd-1
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-1:9735
- --rpclisten=lnd-1:10009
- --restlisten=lnd-1:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-1:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-2:
hostname: lnd-2
depends_on:
- bitcoind
image: boltz/lnd:0.19.3-beta
restart: on-failure
command:
- --listen=lnd-2:9735
- --rpclisten=lnd-2:10009
- --restlisten=lnd-2:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-2:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-3:
hostname: lnd-3
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-3:9735
- --rpclisten=lnd-3:10009
- --restlisten=lnd-3:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
ports:
- 8081:8081
- 10009:10009
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-3:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-4:
hostname: lnd-4
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-4:9735
- --rpclisten=lnd-4:10009
- --restlisten=lnd-4:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-4:/root/.lnd/
- bitcoin-data:/root/.bitcoin
eclair:
hostname: eclair
depends_on:
- bitcoind
image: boltz/eclair:0.8.0
restart: on-failure
entrypoint: "sh -c 'JAVA_OPTS=-Xmx512m /eclair-node/bin/eclair-node.sh -Declair.datadir=/root/eclair -Declair.printToConsole'"
ports:
- 8082:8080
expose:
- 9735
- 8080
volumes:
- ./data/eclair:/root/eclair
- bitcoin-data:/root/.bitcoin
electrs:
hostname: electrs
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-bitcoin"]
command:
- --electrum-rpc-addr
- electrs:19001
- --http-addr
- electrs:3002
- --daemon-rpc-addr
- bitcoind:18443
- --network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3002/blocks/tip/height"]
timeout: 1s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19001:19001
- 3002:3002
volumes:
- bitcoin-data:/root/.bitcoin
elementsd:
hostname: elementsd
restart: always
image: boltz/elements:latest
expose:
- 31001
ports:
- 31000:31000
- 31002:31002
- 18884:18884
command:
- -chain=liquidregtest
- -txindex=1
- -rest=1
- -server=1
- -rpcallowip=0.0.0.0/0
- -validatepegin=0
- -initialfreecoins=2100000000000000
- -fallbackfee=0.000001
- -rpcbind=0.0.0.0
- -rpcport=18884
- -zmqpubrawtx=tcp://0.0.0.0:31000
- -zmqpubrawblock=tcp://0.0.0.0:31001
- -zmqpubhashblock=tcp://0.0.0.0:31002
- -acceptdiscountct=1
- -creatediscountct=1
volumes:
- elements-data:/root/.elements
electrs-liquid:
hostname: electrs-liquid
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-liquid"]
command:
- --electrum-rpc-addr
- electrs-liquid:19002
- --http-addr
- electrs-liquid:3003
- --daemon-rpc-addr
- elementsd:18884
- --daemon-dir
- /root/.elements
- --network
- liquidregtest
- --parent-network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3003/blocks/tip/height"]
timeout: 15s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19002:19002
- 3003:3003
volumes:
- elements-data:/root/.elements
volumes:
lnbits-data:
bitcoin-data:
elements-data:
nginx-data:
name: nginx-data
driver: local
driver_opts:
type: none
o: bind
device: ./data/boltz-nginx/
-322
View File
@@ -1,322 +0,0 @@
#!/bin/sh
export COMPOSE_PROJECT_NAME=lnbits
boltzcli-sim() {
docker exec -it lnbits-boltz-client-1 boltzcli "$@"
}
bitcoin-cli-sim() {
docker exec lnbits-bitcoind-1 bitcoin-cli -regtest "$@"
}
elements-cli-sim() {
docker exec lnbits-elementsd-1 elements-cli -rpcport=18884 -chain=liquidregtest "$@"
}
# args(i, cmd)
lightning-cli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-clightning-$i-1 lightning-cli --network regtest "$@"
}
# args(i, cmd)
lncli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-lnd-$i-1 lncli --network regtest --rpcserver=lnd-$i:10009 "$@"
}
get-eclair-pubkey() {
while true; do
pubkey=$(docker exec lnbits-eclair-1 curl http://localhost:8080/getinfo -X POST -s -u :lnbits | jq -r .nodeId 2> /dev/null)
pubkeyPrefix=$(echo $pubkey | cut -c1,2)
if [[ "$pubkeyPrefix" == "02" || "$pubkeyPrefix" == "03" ]]; then
echo $pubkey
break
fi
sleep 1
done
}
wait-for-eclair-channel() {
while true; do
state=$(docker exec lnbits-eclair-1 curl http://localhost:8080/channels -X POST -s -u :lnbits | jq -r ".[0].state")
pending=$(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
echo "eclair-1 pendingchannels: $pending, current state: $state"
if [[ "$state" == "NORMAL" ]]; then
break
fi
sleep 1
done
}
# args(i)
fund_boltz_client() {
# first address of seed: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
address="el1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f3mmz5l7uw5pqmx6xf5xy50hsn6vhkm5euwt72x878eq6zxx2z0z676mna6kdq"
echo "funding: $address on boltz-client"
elements-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_clightning_node() {
address=$(lightning-cli-sim $1 newaddr | jq -r .bech32)
echo "funding: $address on clightning-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_lnd_node() {
address=$(lncli-sim $1 newaddress p2wkh | jq -r .address)
echo "funding: $address on lnd-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i, j)
connect_clightning_node() {
pubkey=$(lightning-cli-sim $2 getinfo | jq -r '.id')
lightning-cli-sim $1 connect $pubkey@lnbits-clightning-$2-1:9735 | jq -r '.id'
}
lnbits-regtest-start(){
if ! command -v jq &> /dev/null
then
echo "jq is not installed"
exit
fi
if ! command -v docker &> /dev/null
then
echo "docker is not installed"
exit
fi
if ! command -v docker version &> /dev/null
then
echo "dockerd is not running"
exit
fi
lnbits-regtest-stop
docker compose up -d --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-start-log(){
lnbits-regtest-stop
docker compose up --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-stop(){
docker compose down --volumes
# clean up lightning node data
sudo rm -rf ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4 ./data/boltz/boltz.db ./data/eclair/regtest ./data/boltz-client/liquid-wallet ./data/boltz-client/bitcoin-wallet ./data/boltz-client/wallet ./data/boltz-client/boltz.db
# recreate lightning node data folders preventing permission errors
mkdir ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4
}
lnbits-regtest-restart(){
lnbits-regtest-stop
lnbits-regtest-start
}
boltz-client-init(){
for i in 0 1 2; do
fund_boltz_client
done
elements-cli-sim -generate 3 > /dev/null
}
lnbits-bitcoin-init(){
echo "init_bitcoin_wallet..."
bitcoin-cli-sim createwallet lnbits || bitcoin-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
bitcoin-cli-sim -generate 150 > /dev/null
}
lnbits-elements-init(){
echo "init_elements_wallet..."
elements-cli-sim createwallet lnbits || elements-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
elements-cli-sim -generate 150 > /dev/null
elements-cli-sim rescanblockchain
}
lnbits-init(){
echo "init_lnbits..."
docker exec lnbits-lnbits-1 uv run python tools/create_fake_admin.py
}
lnbits-regtest-init(){
lnbits-bitcoin-init
lnbits-elements-init
lnbits-lightning-sync
lnbits-lightning-init
boltz-client-init
lnbits-init
}
lnbits-lightning-sync(){
wait-for-clightning-sync 1
wait-for-clightning-sync 2
wait-for-clightning-sync 3
wait-for-lnd-sync 1
wait-for-lnd-sync 2
wait-for-lnd-sync 3
wait-for-lnd-sync 4
}
lnbits-lightning-init(){
# create 10 UTXOs for each node
for i in 0 1 2; do
fund_clightning_node 1
fund_clightning_node 2
fund_clightning_node 3
fund_lnd_node 1
fund_lnd_node 2
fund_lnd_node 3
fund_lnd_node 4
done
echo "mining 3 blocks..."
bitcoin-cli-sim -generate 3 > /dev/null
lnbits-lightning-sync
channel_confirms=6
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.12 btc
balance_size_msat=12000000000 # 0.12 btc
# lnd-1 -> lnd-2
lncli-sim 1 connect $(lncli-sim 2 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-2-1 > /dev/null
echo "open channel from lnd-1 to lnd-2"
lncli-sim 1 openchannel $(lncli-sim 2 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> lnd-3
lncli-sim 1 connect $(lncli-sim 3 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-3-1 > /dev/null
echo "open channel from lnd-1 to lnd-3"
lncli-sim 1 openchannel $(lncli-sim 3 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-1
lncli-sim 1 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-1 to cln-1"
lncli-sim 1 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-2
lncli-sim 1 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-1 to cln-2"
lncli-sim 1 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-3
lncli-sim 1 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-1 to cln-3"
lncli-sim 1 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> cln-2
lncli-sim 2 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-2 to cln-2"
lncli-sim 2 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
# lnd-3 -> cln-3
lncli-sim 3 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-3 -> cln-1
lncli-sim 3 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-1 -> eclair-1
lncli-sim 1 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 1 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> eclair-1
lncli-sim 2 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 2 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
wait-for-clightning-channel 1
wait-for-clightning-channel 2
wait-for-clightning-channel 3
wait-for-eclair-channel
lnbits-lightning-sync
}
wait-for-lnd-channel(){
while true; do
pending=$(lncli-sim $1 pendingchannels | jq -r '.pending_open_channels | length')
echo "lnd-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
break
fi
sleep 1
done
}
wait-for-lnd-sync(){
while true; do
if [[ "$(lncli-sim $1 getinfo 2>&1 | jq -r '.synced_to_chain' 2> /dev/null)" == "true" ]]; then
echo "lnd-$1 is synced!"
break
fi
echo "waiting for lnd-$1 to sync..."
sleep 1
done
}
wait-for-clightning-channel(){
while true; do
pending=$(lightning-cli-sim $1 getinfo | jq -r '.num_pending_channels | length')
echo "cln-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
break
fi
fi
fi
sleep 1
done
}
wait-for-clightning-sync(){
while true; do
if [[ ! "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.id' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
echo "cln-$1 is synced!"
break
fi
fi
fi
echo "waiting for cln-$1 to sync..."
sleep 1
done
}
-74
View File
@@ -1,74 +0,0 @@
#!/bin/bash
print_success() {
printf "\033[;1;32mPASSED\033[;0m $1\n"
}
print_error() {
printf "\033[;1;31mFAILED\033[;0m $1\n"
}
run(){
label=$1
value=$2
cmd=$3
if [[ "$cmd" == "$value" ]]; then
print_success "$label is $cmd"
else
print_error "$label is $cmd, should be $value"
failed="true"
fi
}
failed="false"
blockheight=213
utxos=3
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.012 btc
source $(pwd)/docker-scripts.sh
lnbits-regtest-start
echo "=================================="
printf "\033[;1;36mregtest started! starting tests...\033[;0m\n"
echo "=================================="
echo ""
for i in 1 2 3; do
run "lnd-$i .synced_to_chain" "true" $(lncli-sim $i getinfo | jq -r ".synced_to_chain")
run "lnd-$i utxo count" $utxos $(lncli-sim $i listunspent | jq -r ".utxos | length")
run "lnd-$i .block_height" $blockheight $(lncli-sim $i getinfo | jq -r ".block_height")
if [[ "$i" == "1" ]]; then
channel_count=6
else
channel_count=3
fi
run "lnd-$i openchannels" $channel_count $(lncli-sim $i listchannels | jq -r ".channels | length")
run "lnd-$i .channels[0].capacity" $channel_size $(lncli-sim $i listchannels | jq -r ".channels[0].capacity")
run "lnd-$i .channels[0].push_amount_sat" $balance_size $(lncli-sim $i listchannels | jq -r ".channels[0].push_amount_sat")
done
for i in 1 2 3; do
# run "cln-$i blockheight" $blockheight $(lightning-cli-sim $i getinfo | jq -r ".blockheight")
run "cln-$i utxo count" $utxos $(lightning-cli-sim $i listfunds | jq -r ".outputs | length")
run "cln-$i openchannels" 2 $(lightning-cli-sim $i getinfo | jq -r ".num_active_channels")
run "cln-$i channel[0].state" "CHANNELD_NORMAL" $(lightning-cli-sim $i listfunds | jq -r ".channels[0].state")
run "cln-$i channel[0].amount_msat" $(($channel_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].amount_msat" | sed 's/msat//g')
run "cln-$i channel[0].our_amount_msat" $(($balance_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].our_amount_msat" | sed 's/msat//g')
done
run "eclair-1 openchannels" 2 $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
run "eclair-1 blockHeight" $blockheight $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/getinfo -X POST -u :lnbits| jq '.blockHeight')
run "lnbits service status" "200" $(curl -s -o /dev/null -w "%{http_code}" "http://localhost:5001/")
run "boltz service status" "200" $(curl -s -o /dev/null --head -w "%{http_code}" "http://localhost:9001/version")
# return non-zero exit code if a test fails
if [[ "$failed" == "true" ]]; then
echo ""
echo "=================================="
print_error "one more more tests failed"
echo "=================================="
exit 1
else
echo ""
echo "=================================="
print_success "all tests passed! yay!"
echo "=================================="
fi
-41
View File
@@ -1,41 +0,0 @@
ARG LNBITS_TAG=latest
FROM lnbits/lnbits:${LNBITS_TAG}
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y ca-certificates curl gnupg netcat-openbsd git nodejs
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# install sparksidecar
RUN git clone https://github.com/lnbits/spark_sidecar
RUN cd spark_sidecar && npm ci
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNBITS
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="SparkL2Wallet"
ENV LNBITS_RESERVE_FEE_MIN="20000"
ENV LNBITS_RESERVE_FEE_PERCENT="1"
ENV LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS="20"
ENV FUNDING_SOURCE_MAX_RETRIES="10"
# spark sidecar
ENV SPARK_NETWORK="MAINNET"
ENV SPARK_SIDECAR_PORT="8765"
ENV SPARK_PAY_WAIT_MS="20000"
ENV SPARK_MULTIPLICITY="3"
EXPOSE 5000
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Entrypoint to start sparksidebar and LNbits
CMD ["/entrypoint.sh"]
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set -e
node spark_sidecar/server.mjs &
SIDECAR_PID=$!
# Wait for startup
for i in {1..10}; do
if nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar is up!"
break
fi
echo "Waiting for sparksidebar to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
-1
View File
@@ -4,7 +4,6 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png" logo: "/logos/lnbits-full-inverse.png"
search_enabled: true search_enabled: true
url: https://docs.lnbits.org url: https://docs.lnbits.org
markdown: gfm
aux_links: aux_links:
"LNbits on GitHub": "LNbits on GitHub":
- "//github.com/lnbits/lnbits" - "//github.com/lnbits/lnbits"
+22 -34
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Admin UI
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -18,13 +12,11 @@ nav_order: 1
# LNBits Admin UI # LNBits Admin UI
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides) We introduced the Admin UI as the new default to make setup simpler and more straightforward. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again. <ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md) For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**. For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" /> <img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
@@ -44,27 +36,6 @@ For a complete reference of legacy variables consult **[.env.example](../../.env
> [!NOTE] > [!NOTE]
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users. > See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User.
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Enabling or disabling the Admin UI ## Enabling or disabling the Admin UI
The Admin UI is enabled by default on new installs. To change the state: The Admin UI is enabled by default on new installs. To change the state:
@@ -105,6 +76,23 @@ The Admin UI is enabled by default on new installs. To change the state:
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid. Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User. If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Allowed Users ## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly. When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
@@ -117,8 +105,8 @@ When set **at least one**, LNbits becomes private: only the listed users and Adm
## Additional Guides ## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance. - **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits. - **[User Roles](./User_Roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each. - **[Funding sources](./funding-sources_table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits. - **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits ## Powered by LNbits
@@ -127,7 +115,7 @@ LNbits empowers everyone with modular, open source tools for building Bitcoin ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de) If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) [![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
-6
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install # Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide. Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
@@ -1,9 +1,3 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters ## Defining a route with path parameters
**old:** **old:**
+2 -11
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -50,7 +44,6 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. | | **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. | | **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. | | **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. | | **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
@@ -58,9 +51,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. | | **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). | | **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
## Spark (L2) ---
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. Sidecar repo `https://github.com/lnbits/spark_sidecar`.
### Notes for readers ### Notes for readers
@@ -81,7 +72,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de) If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) [![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
+27 -33
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Installation
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -57,8 +51,8 @@ LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most syst
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage. - LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
> [!NOTE] > [!NOTE]
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers) ## Option 2: UV (recommended for developers)
@@ -123,8 +117,8 @@ uv run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level #### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE] > [!NOTE]
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_ ## Option 2a (Legacy): Poetry — _Replaced by UV_
@@ -203,8 +197,8 @@ poetry run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level #### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> </details>
@@ -223,8 +217,8 @@ chmod +x lnbits.sh &&
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2). - You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> </details>
@@ -292,7 +286,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps** > **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> </details>
@@ -346,8 +340,8 @@ docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> </details>
@@ -386,14 +380,14 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings. You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949) > ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. > Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has > Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use > `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`. > `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949) > ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949)
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime. > Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source): > Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>` > `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
``` ```
@@ -451,8 +445,8 @@ poetry add setuptools wheel
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b)
> >
> **Next steps** > **Next steps**
> Install complete → **[Running LNbits](#run-the-server)** > Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)** > Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Troubleshooting ## Troubleshooting
@@ -530,22 +524,22 @@ Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
> [!NOTE] > [!NOTE]
> **Paths overview** > **Paths overview**
> >
> - **SuperUser file:** `<lnbits_root>/data/.super_user` > - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user` > Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs) > - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`. > - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`. > On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root). > The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP] > [!TIP]
> **Local Lightning test network** > **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup. > Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/ > https://lightningpolar.com/
> [!TIP] > [!TIP]
> **API comparison before updates** > **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating. > Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable. > Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/ > https://arbadacarbayk.github.io/LNbits_TableTown/
# Additional guides # Additional guides
@@ -890,5 +884,5 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com) If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) [![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+2 -8
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -116,8 +110,8 @@ These are practical tips for running a safe and friendly instance.
## Additional guides ## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks. - **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do. - **[User Roles](./User_Roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them. - **[Funding sources](./funding-sources_table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits. - **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits ## Powered by LNbits
+3 -9
View File
@@ -1,9 +1,3 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -20,7 +14,7 @@ nav_order: 1
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`. ### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:** **Jump to:**
[Roles at a Glance](#roles-at-a-glance) • [Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) • [Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) • [Admin](#admin--day-to-day-manager) •
@@ -87,7 +81,7 @@ For using LNbits, not administering it.
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand). - **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns. - **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each. - **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits ## Powered by LNbits
@@ -95,7 +89,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de) If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) [![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
+4 -32
View File
@@ -41,8 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) | | [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) | | [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) | | [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) | | [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Cliche Wallet](#cliche-wallet) | | |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) | | [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) | | [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
| [Fake Wallet](#fake-wallet) | | | | [Fake Wallet](#fake-wallet) | | |
@@ -53,7 +52,7 @@ A backend wallet is selected and configured entirely through LNbits environment
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune)) ### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest) [Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs) Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables** **Environment variables**
@@ -128,33 +127,6 @@ Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Ligh
- `SPARK_URL`: `http://10.147.17.230:9737/rpc` - `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key` - `SPARK_TOKEN`: `secret_access_key`
## Spark L2
Self-custodial funding source using the [Spark L2](https://docs.spark.money/start/overview) network. Requires a Node.js [sidecar](https://github.com/lnbits/spark_sidecar) that bridges lnbits talking to Spark. Works in addition with any Spark-compatible seed (Wallet of Satoshi, BuhoGO, BlitzWallet).
If the sidecar is started with a `mnemonic` then that mnemonic will be used. Otherwhise if a mnemonic is set for the `Spark L2` LNbits funding source then that mnemonic will be used.
### Optional tuning
- `SPARK_L2_PAY_WAIT_MS`: `4000` _(payment timeout in ms)_
- `SPARK_L2_PAY_POLL_MS`: `500` _(polling interval in ms)_
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000` _(SSE keepalive in ms)_
### Example: run the sidecar
```bash
git clone https://github.com/lnbits/spark_sidecar.git
cd spark_sidecar
npm install
SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom" \
SPARK_NETWORK=MAINNET \
SPARK_SIDECAR_PORT=8765 \
SPARK_PAY_WAIT_MS=20000 \
node server.mjs
```
For testing, you can generate a 12-word mnemonic at https://iancoleman.io/bip39/. Store it securely — it controls your funds. Then select Spark (L2) as the funding source in LNbits.
## LND (REST) ## LND (REST)
**Required env vars** **Required env vars**
@@ -382,8 +354,8 @@ Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.s
## Additional Guides ## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand). - **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits. - **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each. - **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits ## Powered by LNbits
-2
View File
@@ -1,6 +1,5 @@
from .core.services import create_invoice, pay_invoice from .core.services import create_invoice, pay_invoice
from .decorators import ( from .decorators import (
check_account_exists,
check_admin, check_admin,
check_super_user, check_super_user,
check_user_exists, check_user_exists,
@@ -12,7 +11,6 @@ from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
"InvoiceError", "InvoiceError",
"PaymentError", "PaymentError",
"check_account_exists",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",
-2
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db from .db import core_app_extra, db
from .views.admin_api import admin_router from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router from .views.callback_api import callback_router
@@ -45,7 +44,6 @@ def init_core_routers(app: FastAPI):
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router) app.include_router(fiat_router)
app.include_router(lnurl_router) app.include_router(lnurl_router)
-108
View File
@@ -1,108 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
+1 -2
View File
@@ -16,12 +16,11 @@ async def get_audit_entries(
conn: Connection | None = None, conn: Connection | None = None,
) -> Page[AuditEntry]: ) -> Page[AuditEntry]:
return await (conn or db).fetch_page( return await (conn or db).fetch_page(
"SELECT * FROM audit", "SELECT * from audit",
[], [],
{}, {},
filters=filters, filters=filters,
model=AuditEntry, model=AuditEntry,
table_name="audit",
) )
+14 -36
View File
@@ -1,4 +1,3 @@
from datetime import datetime, timezone
from time import time from time import time
from typing import Any from typing import Any
@@ -34,12 +33,13 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment( async def get_standalone_payment(
checking_id_or_hash: str, checking_id_or_hash: str,
conn: Connection | None = None,
incoming: bool | None = False, incoming: bool | None = False,
wallet_id: str | None = None, wallet_id: str | None = None,
conn: Connection | None = None,
) -> Payment | None: ) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash" clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = { values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash, "checking_id": checking_id_or_hash,
"hash": checking_id_or_hash, "hash": checking_id_or_hash,
} }
@@ -47,10 +47,6 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0" clause = f"({clause}) AND amount > 0"
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id" clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
@@ -70,16 +66,13 @@ async def get_standalone_payment(
async def get_wallet_payment( async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None: ) -> Payment | None:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone( payment = await (conn or db).fetchone(
""" """
SELECT * SELECT *
FROM apipayments FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash WHERE wallet_id = :wallet AND payment_hash = :hash
""", """,
{"wallet": wallet.source_wallet_id, "hash": payment_hash}, {"wallet": wallet_id, "hash": payment_hash},
Payment, Payment,
) )
return payment return payment
@@ -125,6 +118,7 @@ async def get_payments_paginated( # noqa: C901
Filters payments to be returned by: Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming. - complete | pending | failed | outgoing | incoming.
""" """
values: dict[str, Any] = { values: dict[str, Any] = {
"time": since, "time": since,
} }
@@ -134,11 +128,7 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}") clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) values["wallet_id"] = wallet_id
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id") clause.append("wallet_id = :wallet_id")
elif user_id: elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn) only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
@@ -181,7 +171,6 @@ async def get_payments_paginated( # noqa: C901
values, values,
filters=filters, filters=filters,
model=Payment, model=Payment,
table_name="apipayments",
) )
@@ -293,7 +282,6 @@ async def create_payment(
fee=-abs(data.fee), fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [],
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
@@ -305,16 +293,8 @@ 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: Connection | None = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
f""" "UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
UPDATE apipayments {"new_id": new_checking_id, "old_id": checking_id},
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
) )
@@ -323,7 +303,6 @@ async def update_payment(
new_checking_id: str | None = None, new_checking_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
) )
@@ -335,14 +314,13 @@ async def get_payments_history(
wallet_id: str | None = None, wallet_id: str | None = None,
group: DateTrunc = "day", group: DateTrunc = "day",
filters: Filters | None = None, filters: Filters | None = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]: ) -> list[PaymentHistoryPoint]:
if not filters: if not filters:
filters = Filters() filters = Filters()
date_trunc = db.datetime_grouping(group) date_trunc = db.datetime_grouping(group)
values: dict[str, Any] = { values = {
"wallet_id": wallet_id, "wallet_id": wallet_id,
} }
# count outgoing payments if they are still pending # count outgoing payments if they are still pending
@@ -371,13 +349,13 @@ async def get_payments_history(
filters.values(values), filters.values(values),
) )
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if wallet:
return [] balance = wallet.balance_msat
balance = wallet.balance_msat else:
values["wallet_id"] = wallet.source_wallet_id raise ValueError("Unknown wallet")
else: else:
balance = await get_total_balance(conn=conn) balance = await get_total_balance()
# since we dont know the balance at the starting point, # since we dont know the balance at the starting point,
# we take the current balance and walk backwards # we take the current balance and walk backwards
+1 -2
View File
@@ -105,8 +105,7 @@ async def get_settings_field(
) )
if not row: if not row:
return None return None
value = json.loads(row["value"]) if row["value"] else None return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
return SettingsField(id=row["id"], value=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: Any | None, tag: str | None = "core"):
+30 -95
View File
@@ -4,12 +4,10 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import ( from ..models import (
Account, Account,
@@ -32,9 +30,9 @@ async def create_account(
return account return account
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await db.update("accounts", account)
return account return account
@@ -43,7 +41,6 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user", "DELETE from accounts WHERE id = :user",
{"user": user_id}, {"user": user_id},
) )
await clear_user_id_cache(user_id)
async def get_accounts( async def get_accounts(
@@ -72,7 +69,6 @@ async def get_accounts(
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id, accounts.external_id,
accounts.activated,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -93,22 +89,15 @@ async def get_accounts(
filters=filters, filters=filters,
model=AccountOverview, model=AccountOverview,
group_by=["accounts.id"], group_by=["accounts.id"],
table_name="accounts",
) )
async def get_account( async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE id = :id",
SELECT * FROM accounts {"id": user_id},
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
Account, Account,
) )
@@ -134,98 +123,66 @@ async def delete_accounts_no_wallets(
async def get_account_by_username( async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None username: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(username) = :username",
SELECT * FROM accounts {"username": username.lower()},
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, conn: Connection | None = None pubkey: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
SELECT * FROM accounts {"pubkey": pubkey.lower()},
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None email: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(email) = :email",
SELECT * FROM accounts {"email": email.lower()},
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, username_or_email: str, conn: Connection | None = None
active_only: bool = True,
conn: Connection | None = None,
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(email) = :value or LOWER(username) = :value
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
""", """,
{"value": username_or_email.lower(), "activated": active_only}, {"value": username_or_email.lower()},
Account, Account,
) )
async def get_user( async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
user_id: str, active_only: bool = True, conn: Connection | None = None account = await get_account(user_id, conn)
) -> User | None: if not account:
async with db.reuse_conn(conn) if conn else db.connect() as conn: return None
account = await get_account(user_id, active_only, conn=conn) return await get_user_from_account(account, conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
async def get_user_from_account( async def get_user_from_account(
account: Account, conn: Connection | None = None account: Account, conn: Connection | None = None
) -> User | None: ) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: extensions = await get_user_active_extensions_ids(account.id, conn)
extensions = await get_user_active_extensions_ids(account.id, conn=conn) wallets = await get_wallets(account.id, False, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User( return User(
id=account.id, id=account.id,
activated=account.activated,
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
@@ -239,43 +196,21 @@ async def get_user_from_account(
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers, fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
) )
async def update_user_access_control_list( async def update_user_access_control_list(user_acls: UserAcls):
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc) user_acls.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", user_acls) await db.update("accounts", user_acls)
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, conn: Connection | None = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
""" "SELECT id, access_control_list FROM accounts WHERE id = :id",
SELECT id, access_control_list FROM accounts {"id": user_id},
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
UserAcls, UserAcls,
) )
return user_acls or UserAcls(id=user_id) return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
+14 -123
View File
@@ -3,10 +3,9 @@ from time import time
from uuid import uuid4 from uuid import uuid4
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet from ..models import Wallet
@@ -15,22 +14,17 @@ async def create_wallet(
*, *,
user_id: str, user_id: str,
wallet_name: str | None = None, wallet_name: str | None = None,
wallet_type: WalletType = WalletType.LIGHTNING,
shared_wallet_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Wallet: ) -> Wallet:
wallet_id = uuid4().hex wallet_id = uuid4().hex
wallet = Wallet( wallet = Wallet(
id=wallet_id, id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name, name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
user=user_id, user=user_id,
adminkey=uuid4().hex, adminkey=uuid4().hex,
inkey=uuid4().hex, inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD", currency=settings.lnbits_default_accounting_currency or "USD",
) )
await (conn or db).insert("wallets", wallet) await (conn or db).insert("wallets", wallet)
return wallet return wallet
@@ -45,14 +39,13 @@ async def update_wallet(
async def delete_wallet( async def delete_wallet(
*,
user_id: str, user_id: str,
wallet_id: str, wallet_id: str,
deleted: bool = True, deleted: bool = True,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
@@ -65,7 +58,6 @@ 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: Connection | None = None) -> None:
clear_wallet_id_cache(wallet_id)
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet", "DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id}, {"wallet": wallet_id},
@@ -75,7 +67,6 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id( async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None wallet_id: str, conn: Connection | None = None
) -> int | None: ) -> int | None:
clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
result = await (conn or db).execute( result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
@@ -112,7 +103,7 @@ async def delete_unused_wallets(
) )
async def get_standalone_wallet( async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None: ) -> Wallet | None:
query = """ query = """
@@ -130,23 +121,8 @@ async def get_standalone_wallet(
) )
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets( async def get_wallets(
user_id: str, user_id: str, deleted: bool | None = False, conn: Connection | None = None
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]: ) -> list[Wallet]:
query = """ query = """
SELECT *, COALESCE(( SELECT *, COALESCE((
@@ -156,20 +132,12 @@ async def get_wallets(
""" """
if deleted is not None: if deleted is not None:
query += " AND deleted = :deleted " query += " AND deleted = :deleted "
if wallet_type is not None: return await (conn or db).fetchall(
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query, query,
{ {"user": user_id, "deleted": deleted},
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet, Wallet,
) )
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated( async def get_wallets_paginated(
user_id: str, user_id: str,
@@ -181,7 +149,7 @@ async def get_wallets_paginated(
deleted = False deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """] where: list[str] = [""" "user" = :user AND deleted = :deleted """]
wallets = await (conn or db).fetch_page( return await (conn or db).fetch_page(
""" """
SELECT *, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -191,27 +159,20 @@ async def get_wallets_paginated(
values={"user": user_id, "deleted": deleted}, values={"user": user_id, "deleted": deleted},
filters=filters, filters=filters,
model=Wallet, model=Wallet,
table_name="wallets",
) )
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids( async def get_wallets_ids(
user_id: str, deleted: bool | None = False, conn: Connection | None = None user_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> list[str]: ) -> list[str]:
query = """SELECT * FROM wallets WHERE "user" = :user""" query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None: if deleted is not None:
query += " AND deleted = :deleted " query += " AND deleted = :deleted"
wallets = await (conn or db).fetchall( result: list[dict] = await (conn or db).fetchall(
query, query,
{"user": user_id, "deleted": deleted}, {"user": user_id, "deleted": deleted},
Wallet,
) )
return [row["id"] for row in result]
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count(): async def get_wallets_count():
@@ -224,90 +185,20 @@ async def get_wallet_for_key(
key: str, key: str,
conn: Connection | None = None, conn: Connection | None = None,
) -> Wallet | None: ) -> Wallet | None:
wallet = await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT wallets.*, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) ), 0)
AS balance_msat FROM wallets AS balance_msat FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id WHERE (adminkey = :key OR inkey = :key) AND deleted = false
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""", """,
{"key": key}, {"key": key},
Wallet, Wallet,
) )
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None): async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances") result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first() row = result.mappings().first()
return row.get("balance", 0) or 0 return row.get("balance", 0) or 0
def clear_wallet_id_cache(wallet_id: str):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
def clear_wallet_cache(wallet: Wallet):
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
-121
View File
@@ -743,124 +743,3 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
""" """
) )
async def m035_add_wallet_type_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
"""
)
async def m036_add_shared_wallet_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
"""
)
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
"""
)
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)
async def m043_add_ui_customization_to_accounts(db: Connection):
"""
Adds ui_customization column to accounts.
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
+2 -2
View File
@@ -46,7 +46,7 @@ from .users import (
UserAcls, UserAcls,
UserExtra, UserExtra,
) )
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __all__ = [
@@ -57,6 +57,7 @@ __all__ = [
"AuditEntry", "AuditEntry",
"AuditFilters", "AuditFilters",
"BalanceDelta", "BalanceDelta",
"BaseWallet",
"Callback", "Callback",
"CancelInvoice", "CancelInvoice",
"ConversionData", "ConversionData",
@@ -98,7 +99,6 @@ __all__ = [
"UserAcls", "UserAcls",
"UserExtra", "UserExtra",
"Wallet", "Wallet",
"WalletInfo",
"WalletTypeInfo", "WalletTypeInfo",
"WebPushSubscription", "WebPushSubscription",
] ]
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
+2 -81
View File
@@ -6,13 +6,12 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field from pydantic import BaseModel
from lnbits.helpers import ( from lnbits.helpers import (
download_url, download_url,
@@ -21,7 +20,6 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
@@ -269,30 +267,10 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await cls.fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
extension_releases = [ return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
] ]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
return [] return []
@@ -628,37 +606,6 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls, cls,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
@@ -797,32 +744,6 @@ class ExtensionDetailsRequest(BaseModel):
version: str version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any: async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token: if settings.lnbits_ext_github_token:
-25
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import random
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -57,29 +55,6 @@ class DataField(BaseModel):
field_type += ' = "sat"' field_type += ' = "sat"'
return f"{field_name}: {field_type}" return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str: def field_to_js(self) -> str:
field_name = camel_to_snake(self.name) field_name = camel_to_snake(self.name)
default_value = "null" default_value = "null"
+55 -36
View File
@@ -6,16 +6,23 @@ from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus, FiatPaymentStatus,
FiatPaymentSuccessStatus,
) )
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus, PaymentStatus,
PaymentSuccessStatus,
) )
@@ -52,7 +59,6 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel): class Payment(BaseModel):
@@ -75,7 +81,6 @@ class Payment(BaseModel):
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {} extra: dict = {}
def __init__(self, **data): def __init__(self, **data):
@@ -123,45 +128,65 @@ class Payment(BaseModel):
"fiat_" "fiat_"
) )
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status( async def check_status(
self, skip_internal_payment_notifications: bool | None = False self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus: ) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.") if self.is_internal:
from lnbits.core.services.payments import check_payment_status if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status( async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus: ) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.") if not self.is_internal:
from lnbits.core.services.fiat_providers import check_fiat_status return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
return await check_fiat_status(self, skip_internal_payment_notifications) if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = [ __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = [ __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
]
status: str | None status: str | None
tag: str | None tag: str | None
@@ -173,7 +198,6 @@ class PaymentFilters(FilterModel):
preimage: str | None preimage: str | None
payment_hash: str | None payment_hash: str | None
wallet_id: str | None wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel): class PaymentDataPoint(BaseModel):
@@ -248,7 +272,6 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@@ -297,7 +320,3 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
+8 -94
View File
@@ -12,7 +12,6 @@ from lnbits.db import FilterModel
from lnbits.helpers import ( from lnbits.helpers import (
is_valid_email_address, is_valid_email_address,
is_valid_external_id, is_valid_external_id,
is_valid_label,
is_valid_pubkey, is_valid_pubkey,
is_valid_username, is_valid_username,
) )
@@ -30,21 +29,6 @@ class UserNotifications(BaseModel):
incoming_payments_sats: int = 0 incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: str
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: bool | None = False email_verified: bool | None = False
first_name: str | None = None first_name: str | None = None
@@ -62,55 +46,6 @@ class UserExtra(BaseModel):
notifications: UserNotifications = UserNotifications() notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel): class EndpointAccess(BaseModel):
path: str path: str
@@ -172,23 +107,14 @@ class UserAcls(BaseModel):
return None return None
class AccountId(BaseModel): class Account(BaseModel):
id: str id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: str | None = None pubkey: str | None = None
email: str | None = None email: str | None = None
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -203,10 +129,6 @@ class Account(AccountId):
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id) self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
salt = gensalt() salt = gensalt()
@@ -238,11 +160,8 @@ class Account(AccountId):
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account): class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0 transaction_count: int | None = 0
wallet_count: int | None = 0 wallet_count: int | None = 0
balance_msat: int | None = 0 balance_msat: int | None = 0
@@ -251,7 +170,7 @@ class AccountOverview(Account):
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
__search_fields__ = [ __search_fields__ = [
"id", "user",
"email", "email",
"username", "username",
"pubkey", "pubkey",
@@ -259,18 +178,17 @@ class AccountFilters(FilterModel):
"wallet_id", "wallet_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
"id", "balance_msat",
"email", "email",
"username", "username",
"pubkey", "transaction_count",
"external_id", "wallet_count",
"created_at", "last_payment",
"updated_at",
] ]
id: str | None = None
username: str | None = None
email: str | None = None email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None external_id: str | None = None
wallet_id: str | None = None wallet_id: str | None = None
@@ -278,7 +196,6 @@ class AccountFilters(FilterModel):
class User(BaseModel): class User(BaseModel):
id: str id: str
activated: bool = True
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
email: str | None = None email: str | None = None
@@ -292,7 +209,6 @@ class User(BaseModel):
fiat_providers: list[str] = [] fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property @property
def wallet_ids(self) -> list[str]: def wallet_ids(self) -> list[str]:
@@ -318,7 +234,6 @@ class RegisterUser(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
invitation_code: str | None = Query(default=None, max_length=256)
class CreateUser(BaseModel): class CreateUser(BaseModel):
@@ -362,7 +277,6 @@ class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
first_install_token: str | None = Query(None)
class LoginUsr(BaseModel): class LoginUsr(BaseModel):
+11 -172
View File
@@ -4,14 +4,16 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings from lnbits.settings import settings
class WalletInfo(BaseModel): class BaseWallet(BaseModel):
id: str id: str
name: str name: str
adminkey: str adminkey: str
@@ -19,109 +21,18 @@ class WalletInfo(BaseModel):
balance_msat: int balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel): class WalletExtra(BaseModel):
icon: str = "flash_on" icon: str = "flash_on"
color: str = "primary" color: str = "primary"
pinned: bool = False pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class BaseWallet(BaseModel): class Wallet(BaseModel):
id: str id: str
user: str user: str
wallet_type: str = WalletType.LIGHTNING.value name: str
adminkey: str adminkey: str
inkey: str inkey: str
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -129,65 +40,6 @@ class Wallet(BaseWallet):
balance_msat: int = Field(default=0, no_database=True) balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks() stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property @property
def balance(self) -> int: def balance(self) -> int:
@@ -198,23 +50,16 @@ class Wallet(BaseWallet):
return self.balance_msat - settings.fee_reserve(self.balance_msat) return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property @property
def is_lightning_wallet(self) -> bool: def lnurlwithdraw_full(self) -> str:
return self.wallet_type == WalletType.LIGHTNING.value url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
@property return lnurl_encode(url)
def is_lightning_shared_wallet(self) -> bool: except Exception:
return self.wallet_type == WalletType.LIGHTNING_SHARED.value return ""
def _validate_data(self):
if self.is_lightning_shared_wallet:
if not self.shared_wallet_id:
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel): class CreateWallet(BaseModel):
name: str | None = None name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum): class KeyType(Enum):
@@ -233,12 +78,6 @@ class WalletTypeInfo:
wallet: Wallet wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel): class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"] __search_fields__ = ["id", "name", "currency"]
-4
View File
@@ -1,4 +1,3 @@
from .fiat_providers import check_fiat_status
from .funding_source import ( from .funding_source import (
get_balance_delta, get_balance_delta,
switch_to_voidwallet, switch_to_voidwallet,
@@ -8,7 +7,6 @@ from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
cancel_hold_invoice, cancel_hold_invoice,
check_payment_status,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice, create_fiat_invoice,
@@ -42,8 +40,6 @@ __all__ = [
"calculate_fiat_amounts", "calculate_fiat_amounts",
"cancel_hold_invoice", "cancel_hold_invoice",
"check_admin_settings", "check_admin_settings",
"check_fiat_status",
"check_payment_status",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"check_webpush_settings", "check_webpush_settings",
-70
View File
@@ -1,70 +0,0 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents,
)
await create_asset(asset)
return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+2 -3
View File
@@ -16,7 +16,6 @@ from lnbits.core.crud.extensions import (
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -150,9 +149,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions( async def get_valid_extensions(
include_deactivated: bool | None = True, conn: Connection | None = None include_deactivated: bool | None = True,
) -> list[Extension]: ) -> list[Extension]:
installed_extensions = await get_installed_extensions(conn=conn) installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions] valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated: if include_deactivated:
+11 -44
View File
@@ -20,7 +20,6 @@ from lnbits.helpers import (
camel_to_words, camel_to_words,
download_url, download_url,
lowercase_first_letter, lowercase_first_letter,
snake_to_camel,
) )
from lnbits.settings import settings from lnbits.settings import settings
@@ -196,29 +195,13 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id) ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True) shutil.rmtree(ext_build_dir, True)
shutil.copytree( shutil.copytree(ext_stub_cache_dir, ext_build_dir)
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None: def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data) parsed_data = _parse_extension_data(data)
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")] for py_file in py_files:
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix() template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data) rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f: with open(template_path, "w", encoding="utf-8") as f:
@@ -226,7 +209,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker) _remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "index.js").as_posix() template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
rederer = _render_file( rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data} template_path, {"preview": data.preview_action, **parsed_data}
) )
@@ -251,7 +234,9 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data", "settingsFormDialog.data",
ext_stub_dir, ext_stub_dir,
) )
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix() template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
rederer = _render_file( rederer = _render_file(
template_path, template_path,
{ {
@@ -278,12 +263,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData", "publicClientData",
ext_stub_dir, ext_stub_dir,
) )
public_template_path = Path(ext_stub_dir, "static", "public_page.vue") public_template_path = Path(
public_component_path = Path(ext_stub_dir, "static", "public_page.js") ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
template_path = public_template_path.as_posix() template_path = public_template_path.as_posix()
if not data.public_page.has_public_page: if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True) public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else: else:
rederer = _render_file( rederer = _render_file(
template_path, template_path,
@@ -323,10 +308,8 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None: def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix() extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = { rename_values = {
"extension_builder_stub_name": data.name, "extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "", "extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id, "extension_builder_stub": data.id,
"OwnerData": data.owner_data.name, "OwnerData": data.owner_data.name,
@@ -345,7 +328,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path, directory=extension_dir_path,
old_text=old_text, old_text=old_text,
new_text=new_text, new_text=new_text,
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"], file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
) )
_rename_files_and_dirs_in_directory( _rename_files_and_dirs_in_directory(
@@ -423,7 +406,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": { "owner_data": {
"name": data.owner_data.name, "name": data.owner_data.name,
"editable": data.owner_data.editable, "editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [ "js_fields": [
field.field_to_js() field.field_to_js()
for field in data.owner_data.fields for field in data.owner_data.fields
@@ -450,18 +432,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.owner_data.fields], "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], "all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
}, },
"client_data": { "client_data": {
"name": data.client_data.name, "name": data.client_data.name,
@@ -487,9 +457,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.client_data.fields], "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], "all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
}, },
"settings_data": { "settings_data": {
"enabled": data.settings_data.enabled, "enabled": data.settings_data.enabled,
@@ -569,7 +536,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}") logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path: str) -> bool: def _is_excluded_dir(path):
for excluded_dir in excluded_dirs: for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir): if path.startswith(excluded_dir):
return True return True
-99
View File
@@ -1,9 +1,7 @@
import hashlib import hashlib
import hmac import hmac
import json
import time import time
import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
@@ -12,12 +10,6 @@ from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings from lnbits.settings import settings
@@ -35,40 +27,6 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
return FiatPaymentSuccessStatus()
if payment.failed:
return FiatPaymentFailedStatus()
if not payment.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = payment.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(payment.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
return fiat_status
def check_stripe_signature( def check_stripe_signature(
payload: bytes, payload: bytes,
sig_header: str | None, sig_header: str | None,
@@ -112,63 +70,6 @@ def check_stripe_signature(
raise ValueError("Stripe signature verification failed.") raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set.")
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers.")
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.
+1 -2
View File
@@ -70,8 +70,7 @@ async def check_balance_delta_changed():
if settings.latest_balance_delta_sats is None: if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
return return
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats) if status.delta_sats != settings.latest_balance_delta_sats:
if delta_change >= settings.notification_balance_delta_threshold_sats:
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.balance_delta, NotificationType.balance_delta,
{ {
+1 -20
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import smtplib import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
@@ -17,7 +16,6 @@ from lnbits.core.crud import (
mark_webhook_sent, mark_webhook_sent,
) )
from lnbits.core.crud.users import get_user from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Payment, Wallet from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import ( from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
@@ -242,10 +240,6 @@ async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
check_callback_url(payment.webhook) check_callback_url(payment.webhook)
except ValueError as exc:
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
try:
r = await client.post(payment.webhook, json=payment.json(), timeout=40) r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status() r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code)) await mark_webhook_sent(payment.payment_hash, str(r.status_code))
@@ -263,12 +257,6 @@ async def dispatch_webhook(payment: Payment):
async def send_payment_notification(wallet: Wallet, payment: Payment): async def send_payment_notification(wallet: Wallet, payment: Payment):
try: try:
await send_ws_payment_notification(wallet, payment) await send_ws_payment_notification(wallet, payment)
for shared in wallet.extra.shared_with:
if not shared.shared_with_wallet_id:
continue
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
if shared_wallet and shared_wallet.can_view_payments:
await send_ws_payment_notification(shared_wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}") logger.error(f"Error sending websocket payment notification {e!s}")
try: try:
@@ -287,13 +275,6 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
logger.error(f"Error dispatching webhook: {e!s}") logger.error(f"Error dispatching webhook: {e!s}")
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
try:
create_task(send_payment_notification(wallet, payment))
except Exception as e:
logger.warning(f"Error sending payment notification: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send(wallet.inkey, payment.json())
@@ -400,7 +381,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.watchdog_check: if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta: if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_threshold_sats > 0 return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop: if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status: if message_type == NotificationType.server_status:
+30 -92
View File
@@ -25,7 +25,6 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
InvoiceResponse, InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@@ -48,8 +47,7 @@ from ..models import (
PaymentState, PaymentState,
Wallet, Wallet,
) )
from .fiat_providers import check_fiat_status from .notifications import send_payment_notification
from .notifications import send_payment_notification_in_background
payment_lock = asyncio.Lock() payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {} wallets_payments_lock: dict[str, asyncio.Lock] = {}
@@ -63,13 +61,11 @@ async def pay_invoice(
extra: dict | None = None, extra: dict | None = None,
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat) invoice = _validate_payment_request(payment_request, max_sat)
if not invoice.amount_msat: if not invoice.amount_msat:
raise ValueError("Missig invoice amount.") raise ValueError("Missig invoice amount.")
@@ -77,34 +73,25 @@ async def pay_invoice(
amount_msat = invoice.amount_msat amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if not wallet.can_send_payments:
raise PaymentError(
"Wallet does not have permission to pay invoices.",
status="failed",
)
if await is_internal_status_success(invoice.payment_hash, new_conn): if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed") raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra) _, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount_msat=-amount_msat, amount_msat=-amount_msat,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels,
) )
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
payment = await _pay_invoice( await _credit_service_fee_wallet(wallet, payment, new_conn)
wallet.source_wallet_id, create_payment_model, conn=new_conn
)
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
return payment return payment
@@ -203,22 +190,19 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
# do not save memo if description_hash or unhashed_description is set # do not save memo if description_hash or unhashed_description is set
memo = "" memo = ""
async with db.connect() as conn: payment = await create_invoice(
payment = await create_invoice( wallet_id=wallet_id,
wallet_id=wallet_id, amount=data.amount,
amount=data.amount, memo=memo,
memo=memo, currency=data.unit,
currency=data.unit, description_hash=description_hash,
description_hash=description_hash, unhashed_description=unhashed_description,
unhashed_description=unhashed_description, expiry=data.expiry,
expiry=data.expiry, extra=data.extra,
extra=data.extra, webhook=data.webhook,
webhook=data.webhook, internal=data.internal,
internal=data.internal, payment_hash=data.payment_hash,
payment_hash=data.payment_hash, )
labels=data.labels,
conn=conn,
)
if data.lnurl_withdraw: if data.lnurl_withdraw:
try: try:
@@ -257,7 +241,6 @@ async def create_invoice(
webhook: str | None = None, webhook: str | None = None,
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -267,12 +250,6 @@ async def create_invoice(
if not user_wallet: if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed") raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
if not user_wallet.can_receive_payments:
raise InvoiceError(
"Wallet does not have permission to create invoices.",
status="failed",
)
invoice_memo = None if description_hash else memo[:640] invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only # use the fake wallet if the invoice is for internal use only
@@ -331,7 +308,7 @@ async def create_invoice(
invoice = bolt11_decode(invoice_response.payment_request) invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=user_wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=invoice_response.payment_request, bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage, preimage=invoice_response.preimage,
@@ -341,7 +318,6 @@ async def create_invoice(
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels,
) )
payment = await create_payment( payment = await create_payment(
@@ -363,15 +339,13 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment) await update_pending_payment(payment)
async def update_pending_payment( async def update_pending_payment(payment: Payment) -> Payment:
payment: Payment, conn: Connection | None = None status = await payment.check_status()
) -> Payment:
status = await check_payment_status(payment)
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status)
return payment return payment
@@ -482,7 +456,7 @@ async def update_wallet_balance(
await create_payment( await create_payment(
checking_id=f"internal_{payment_hash}", checking_id=f"internal_{payment_hash}",
data=CreatePayment( data=CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
bolt11=bolt11, bolt11=bolt11,
payment_hash=payment_hash, payment_hash=payment_hash,
amount_msat=amount * 1000, amount_msat=amount * 1000,
@@ -501,7 +475,7 @@ async def update_wallet_balance(
raise ValueError("Balance change failed, amount exceeds maximum balance.") raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice( payment = await create_invoice(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
amount=amount, amount=amount,
memo="Admin credit", memo="Admin credit",
internal=True, internal=True,
@@ -620,29 +594,7 @@ async def check_transaction_status(
if payment.status == PaymentState.SUCCESS.value: if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
return await check_payment_status(payment) return await payment.check_status()
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if payment.is_out:
status = await funding_source.get_payment_status(payment.checking_id)
else:
status = await funding_source.get_invoice_status(payment.checking_id)
return status
async def get_payments_daily_stats( async def get_payments_daily_stats(
@@ -730,7 +682,6 @@ async def _pay_internal_invoice(
internal_payment = await check_internal( internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn create_payment_model.payment_hash, conn=conn
) )
if not internal_payment: if not internal_payment:
return None return None
@@ -739,7 +690,6 @@ async def _pay_internal_invoice(
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn internal_payment.checking_id, incoming=True, conn=conn
) )
if not internal_invoice: if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed") raise PaymentError("Internal payment not found.", status="failed")
@@ -761,7 +711,6 @@ async def _pay_internal_invoice(
internal_id = f"internal_{create_payment_model.payment_hash}" internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment( payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
data=create_payment_model, data=create_payment_model,
@@ -776,7 +725,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(wallet.id, payment, conn=conn) await send_payment_notification(wallet, payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -849,8 +798,7 @@ async def _pay_external_invoice(
payment = await update_payment_success_status( payment = await update_payment_success_status(
payment, payment_response, conn=conn payment, payment_response, conn=conn
) )
await send_payment_notification(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}") logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id payment.checking_id = payment_response.checking_id
@@ -893,7 +841,7 @@ async def _verify_external_payment(
raise PaymentError("Payment already paid.", status="success") raise PaymentError("Payment already paid.", status="success")
# payment failed # payment failed
status = await check_payment_status(payment) status = await payment.check_status()
if status.failed: if status.failed:
raise PaymentError( raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed" "Payment is failed node, retrying is not possible.", status="failed"
@@ -962,7 +910,7 @@ async def _credit_service_fee_wallet(
memo = f""" memo = f"""
Service fee for payment of {abs(payment.sat)} sats. Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.source_wallet_id}).""" Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
@@ -1058,13 +1006,3 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
await update_payment(payment) await update_payment(payment)
return response return response
async def _send_payment_notification_in_background(
wallet_id: str, payment: Payment, conn: Connection | None = None
):
# fetch balance again
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
+24 -62
View File
@@ -3,11 +3,7 @@ from uuid import uuid4
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.db import Connection
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
SuperSettings, SuperSettings,
@@ -52,43 +48,37 @@ async def create_user_account_no_ckeck(
account: Account | None = None, account: Account | None = None,
wallet_name: str | None = None, wallet_name: str | None = None,
default_exts: list[str] | None = None, default_exts: list[str] | None = None,
conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
if account:
account.validate_fields()
if account.username and await get_account_by_username(
account.username, conn=conn
):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email, conn=conn): if account:
raise ValueError("Email already exists.") account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.pubkey and await get_account_by_pubkey( if account.email and await get_account_by_email(account.email):
account.pubkey, conn=conn raise ValueError("Email already exists.")
):
raise ValueError("Pubkey already exists.")
if not account.id: if account.pubkey and await get_account_by_pubkey(account.pubkey):
account.id = uuid4().hex raise ValueError("Pubkey already exists.")
account = await create_account(account, conn=conn) if not account.id:
await create_wallet( account.id = uuid4().hex
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions account = await create_account(account)
for ext_id in user_extensions: await create_wallet(
try: user_id=account.id,
user_ext = UserExtension(user=account.id, extension=ext_id, active=True) wallet_name=wallet_name or settings.lnbits_default_wallet_name,
await create_user_extension(user_ext, conn=conn) )
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account, conn=conn) user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
for ext_id in user_extensions:
try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
if not user: if not user:
raise ValueError("Cannot find user for account.") raise ValueError("Cannot find user for account.")
@@ -173,12 +163,6 @@ async def check_admin_settings():
if account and account.extra and account.extra.provider == "env": if account and account.extra and account.extra.provider == "env":
settings.first_install = True settings.first_install = True
if settings.has_first_install_token_changed():
logger.warning("First install token is changed. Resetting admin settings.")
new_settings = await init_admin_settings()
settings.super_user = new_settings.super_user
settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
@@ -200,25 +184,3 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.dict())
async def check_register_activation_settings(data: RegisterUser):
if not settings.lnbits_require_user_activation:
return None
if settings.lnbits_user_activation_by_invitation_code:
code = data.invitation_code.strip() if data.invitation_code else ""
if len(code) == 0:
raise ValueError("Invitation code cannot be empty.")
if code == settings.lnbits_register_reusable_activation_code:
return None
if code in settings.lnbits_register_one_time_activation_codes:
settings.lnbits_register_one_time_activation_codes.remove(code)
await set_settings_field(
"lnbits_register_one_time_activation_codes",
settings.lnbits_register_one_time_activation_codes,
)
return None
raise ValueError("Invalid invitation code.")
raise ValueError("No activation method provided.")
-202
View File
@@ -1,202 +0,0 @@
from lnbits.core.crud.users import (
get_account,
get_account_by_username_or_email,
update_account,
)
from lnbits.core.crud.wallets import (
create_wallet,
force_delete_wallet,
get_standalone_wallet,
get_wallet,
get_wallets,
update_wallet,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account
from lnbits.core.models.wallets import (
Wallet,
WalletSharePermission,
WalletShareStatus,
WalletType,
)
from lnbits.db import Connection
from lnbits.helpers import sha256s
async def invite_to_wallet(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.username:
raise ValueError("Username or email missing.")
invited_user = await get_account_by_username_or_email(data.username)
if not invited_user:
raise ValueError("Invited user not found.")
request_id = sha256s(invited_user.id + source_wallet.id)
share = source_wallet.extra.find_share_by_id(request_id)
if share:
raise ValueError("User already invited to this wallet.")
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
request_id=request_id,
request_type=WalletShareStatus.INVITE_SENT,
username=data.username,
permissions=data.permissions,
)
await update_wallet(source_wallet)
wallet_owner = await get_account(source_wallet.user)
if not wallet_owner:
raise ValueError("Cannot find wallet owner.")
invited_user.extra.add_wallet_invite_request(
request_id=request_id,
from_user_name=wallet_owner.username or wallet_owner.email,
to_wallet_id=source_wallet.id,
to_wallet_name=source_wallet.name,
)
await update_account(invited_user)
return invite_request
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
invited_user = await get_account(invited_user_id)
if not invited_user:
raise ValueError("Invited user not found.")
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
if not existing_request:
raise ValueError("Invitation not found.")
invited_user.extra.remove_wallet_invite_request(share_request_id)
await update_account(invited_user)
async def update_wallet_share_permissions(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.shared_with_wallet_id:
raise ValueError("Wallet ID missing.")
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
if not share:
raise ValueError("Share not found")
if not share.shared_with_wallet_id:
raise ValueError("Share does not have a mirror wallet ID.")
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
if not mirror_wallet:
raise ValueError("Target wallet not found")
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
share.approve(permissions=data.permissions)
await update_wallet(source_wallet)
return share
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
if not source_wallet.is_lightning_wallet:
raise ValueError("Source wallet is not a lightning wallet.")
share = source_wallet.extra.find_share_by_id(request_id)
if not share:
raise ValueError("Wallet share not found.")
source_wallet.extra.remove_share_by_id(request_id)
invited_user = await get_account_by_username_or_email(share.username)
if not invited_user:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Invited user not found."
)
if invited_user.extra.find_wallet_invite_request(request_id):
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
mirror_wallets = await get_wallets(
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
)
mirror_wallet = next(
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
)
if not mirror_wallet:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Target wallet not found."
)
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared lightning wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
await force_delete_wallet(mirror_wallet.id)
await update_wallet(source_wallet)
return SimpleStatus(success=True, message="Permission removed.")
async def create_lightning_shared_wallet(
user_id: str,
source_wallet_id: str,
conn: Connection | None = None,
) -> Wallet:
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
if not source_wallet:
raise ValueError("Shared wallet does not exist.")
if not source_wallet.is_lightning_wallet:
raise ValueError("Shared wallet is not a lightning wallet.")
if source_wallet.user == user_id:
raise ValueError("Cannot mirror your own wallet.")
invited_user = await get_account(user_id, conn=conn)
if not invited_user:
raise ValueError("Cannot find invited user.")
return await _accept_invitation_to_shared_wallet(
invited_user, source_wallet, conn=conn
)
async def _accept_invitation_to_shared_wallet(
invited_user: Account,
source_wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
request_id = sha256s(invited_user.id + source_wallet.id)
existing_request = source_wallet.extra.find_share_by_id(request_id)
if not existing_request:
raise ValueError("No invitation found for this invited user.")
if existing_request.status == WalletShareStatus.APPROVED:
raise ValueError("This wallet is already shared with you.")
if existing_request.status != WalletShareStatus.INVITE_SENT:
raise ValueError("Unknown request type.")
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
# todo: double check if user already has a mirror wallet for this source wallet
mirror_wallet = await create_wallet(
user_id=invited_user.id,
wallet_name=source_wallet.name,
wallet_type=WalletType.LIGHTNING_SHARED,
shared_wallet_id=source_wallet.id,
conn=conn,
)
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
await update_wallet(source_wallet, conn=conn)
mirror_wallet.mirror_shared_wallet(source_wallet)
return mirror_wallet
+2 -4
View File
@@ -35,7 +35,7 @@ async def run_by_the_minute_tasks() -> None:
while settings.lnbits_running: while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60 status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0: if settings.notification_balance_delta_changed:
try: try:
# runs by default every minute, the delta should not change that often # runs by default every minute, the delta should not change that often
await check_balance_delta_changed() await check_balance_delta_changed()
@@ -57,9 +57,7 @@ async def run_by_the_minute_tasks() -> None:
if minute_counter % 60 == 0: if minute_counter % 60 == 0:
try: try:
# initialize the list of all extensions # initialize the list of all extensions
await InstallableExtension.get_installable_extensions( await InstallableExtension.get_installable_extensions()
post_refresh_cache=True
)
except Exception as ex: except Exception as ex:
logger.error(ex) logger.error(ex)
@@ -1,6 +1,7 @@
<template id="lnbits-admin-audit"> <q-tab-panel name="audit">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6> <h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg"> <div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
@@ -32,7 +33,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -49,12 +50,8 @@
<span v-text="$t('audit_record_warning')"></span> <span v-text="$t('audit_record_warning')"></span>
<br /> <br />
<ul> <ul>
<li> <li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<span v-text="$t('audit_record_req_warning_1')"></span> <li><span v-text="$t('audit_record_req_warning_2')"></span></li>
</li>
<li>
<span v-text="$t('audit_record_req_warning_2')"></span>
</li>
</ul> </ul>
<br /> <br />
<span v-text="$t('audit_record_use')"></span> <span v-text="$t('audit_record_use')"></span>
@@ -62,7 +59,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -83,7 +80,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -104,7 +101,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -129,24 +126,18 @@
<q-separator class="q-mb-xl q-mt-sm"></q-separator> <q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg"> <div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_http_methods')"></span></p>
<q-select <q-select
filled filled
v-model="formData.lnbits_audit_http_methods" v-model="formData.lnbits_audit_http_methods"
multiple multiple
:hint="$t('audit_http_methods_hint')" :hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')" :label="$t('audit_http_methods_label')"
:options="[ :options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS'
]"
></q-select> ></q-select>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_resp_codes')"></span></p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -175,6 +166,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_paths')"></span></p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -201,6 +193,9 @@
<br /> <br />
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<p>
<span v-text="$t('audit_paths_exclude')"></span>
</p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -230,4 +225,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-exchange-providers"> <q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span> <span v-text="$t('exchange_providers')"></span>
</h6> </h6>
@@ -19,7 +19,6 @@
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<q-input <q-input
class="q-mb-md"
filled filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds" v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number" type="number"
@@ -110,7 +109,7 @@
dense dense
filled filled
v-model="props.row.name" v-model="props.row.name"
@update:model-value="formData.touch = null" @update:model-value="touchSettings()"
type="text" type="text"
> >
</q-input> </q-input>
@@ -120,7 +119,7 @@
dense dense
filled filled
v-model="props.row.api_url" v-model="props.row.api_url"
@update:model-value="formData.touch = null" @update:model-value="touchSettings()"
type="text" type="text"
> >
</q-input </q-input
@@ -130,7 +129,7 @@
dense dense
filled filled
v-model="props.row.path" v-model="props.row.path"
@update:model-value="formData.touch = null" @update:model-value="touchSettings()"
type="text" type="text"
> >
</q-input> </q-input>
@@ -140,9 +139,9 @@
filled filled
dense dense
v-model="props.row.exclude_to" v-model="props.row.exclude_to"
@update:model-value="formData.touch = null" @update:model-value="touchSettings()"
multiple multiple
:options="currencies" :options="{{ currencies | safe }}"
></q-select> ></q-select>
</q-td> </q-td>
<q-td> <q-td>
@@ -156,7 +155,7 @@
> >
</q-btn> </q-btn>
<q-chip <q-chip
v-for="(ticker, index) in props.row.ticker_conversion" v-for="ticker, index in props.row.ticker_conversion"
:key="ticker" :key="ticker"
removable removable
dense dense
@@ -199,47 +198,4 @@
</div> </div>
<div class="col-md-8 col-sm-12"></div> <div class="col-md-8 col-sm-12"></div>
</div> </div>
</q-tab-panel>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="currencies"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
</template>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-extensions"> <q-tab-panel name="extensions">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none">
@@ -137,20 +137,7 @@
:hint="$t('extension_builder_manifest_url_hint')" :hint="$t('extension_builder_manifest_url_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6 q-pb-md">
<p>
<span v-text="$t('reviews_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_reviews_url"
:label="$t('reviews_url_label')"
:hint="$t('reviews_url_hint')"
type="url"
autocomplete="off"
></q-input>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -0,0 +1,287 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.completed</code></li>
- the user completed the checkout process
<li><code>invoice.paid</code></li>
- the invoice was successfully paid (for subscriptions)
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-funding"> <q-tab-panel name="funding">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span> <span v-text="$t('wallets_management')"></span>
@@ -12,157 +12,97 @@
</p> </p>
<ul> <ul>
<li <li
v-text=" v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
$t('funding_source', {
wallet_class: settings.lnbits_backend_wallet_class
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
$t('node_balance', {
balance: (auditData.node_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
$t('lnbits_balance', {
balance: (auditData.lnbits_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('funding_reserve_percent', {
$t('funding_reserve_percent', { percent: auditData.lnbits_balance_sats > 0
percent: ? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
auditData.lnbits_balance_sats > 0 : 100
? ( })"
(auditData.node_balance_sats /
auditData.lnbits_balance_sats) *
100
).toFixed(2)
: 100
})
"
></li> ></li>
</ul> </ul>
<br /> <br />
</div> </div>
<div class="col"> <div class="col">
<div v-if="g.settings.hasNodemanager"> {% if LNBITS_NODE_UI_AVAILABLE %}
<p> <p><span v-text="$t('node_management')"></span></p>
<span v-text="$t('node_management')"></span> <q-toggle
</p> :label="$t('toggle_node_ui')"
<q-toggle v-model="formData.lnbits_node_ui"
:label="$t('toggle_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui" <q-toggle
></q-toggle> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_public_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_public_node_ui"
:label="$t('toggle_public_node_ui')" ></q-toggle>
v-model="formData.lnbits_public_node_ui" <br />
></q-toggle> <q-toggle
<br /> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_transactions_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_node_ui_transactions"
:label="$t('toggle_transactions_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui_transactions" {% else %}
></q-toggle> <p><span v-text="$t('node_management_not_supported')"></span></p>
</div> {% endif %}
<p v-if="!g.settings.hasNodemanager">
<span v-text="$t('node_management_not_supported')"></span>
</p>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12"> <div class="col-12 col-md-3">
<h6 class="q-my-none"> <p><span v-text="$t('fee_reserve')"></span></p>
<span v-text="$t('routing_fee_reserve_calculations')"></span>
</h6>
<p class="q-mt-sm q-mb-none">
<span v-html="$t('routing_fee_reserve_calculations_desc')"></span>
</p>
</div>
<div class="col-12 col-md-4">
<p>
<span v-text="$t('fee_reserve')"></span>
<sup>
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
<q-tooltip max-width="300px">
<span v-html="$t('fee_reserve_min_hint')"></span>
</q-tooltip>
</sup>
</p>
<q-input <q-input
type="number" type="number"
filled filled
v-model="formData.lnbits_reserve_fee_min" v-model="formData.lnbits_reserve_fee_min"
:suffix="$t('millisats')" :label="$t('fee_reserve_msats')"
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('fee_reserve_percent')"></span></p>
<span v-text="$t('fee_reserve_percent')"></span>
<sup>
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
<q-tooltip max-width="300px">
<span v-html="$t('fee_reserve_percent_hint')"></span>
</q-tooltip>
</sup>
</p>
<q-input <q-input
type="number" type="number"
filled filled
name="lnbits_reserve_fee_percent" name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent" v-model="formData.lnbits_reserve_fee_percent"
:label="$t('reserve_fee_in_percent')"
step="0.1" step="0.1"
suffix="%"
></q-input> ></q-input>
</div> </div>
</div> <div class="col-12 col-md-3">
<div class="row q-col-gutter-md q-mt-sm">
<div class="col-12">
<h6 class="q-my-none">
<span v-text="$t('payment_timeouts')"></span>
</h6>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('invoice_expiry')"></span></p> <p><span v-text="$t('invoice_expiry')"></span></p>
<q-input <q-input
filled filled
v-model.number="formData.lightning_invoice_expiry" v-model.number="formData.lightning_invoice_expiry"
type="number" type="number"
:suffix="$t('seconds')" :label="$t('invoice_expiry_label')"
mask="#######" mask="#######"
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('payment_wait_time')"></span></p>
<span v-text="$t('payment_wait_time')"></span>
<sup>
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
<q-tooltip max-width="300px">
<span v-text="$t('payment_wait_time_tooltip')"></span>
</q-tooltip>
</sup>
</p>
<q-input <q-input
type="number" type="number"
filled filled
name="lnbits_funding_source_pay_invoice_wait_seconds" name="lnbits_funding_source_pay_invoice_wait_seconds"
v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds" v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds"
:label="$t('payment_wait_time')"
:hint="$t('payment_wait_time_desc')" :hint="$t('payment_wait_time_desc')"
:suffix="$t('seconds')"
step="1" step="1"
min="0" min="0"
></q-input> ></q-input>
</div> </div>
</div> </div>
<div v-if="isSuperUser"> <div v-if="isSuperUser">
<lnbits-admin-funding-sources <lnbits-funding-sources
:form-data="formData" :form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources" :allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/> />
@@ -193,11 +133,6 @@
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p> <p v-text="$t('watchdog')"></p>
</h6> </h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p v-text="$t('watchdog_introduction')"></p>
</div>
</div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
@@ -272,7 +207,6 @@
<q-input <q-input
filled filled
v-model="formData.lnbits_watchdog_delta" v-model="formData.lnbits_watchdog_delta"
:suffix="$t('sats')"
type="number" type="number"
/> />
</q-item-section> </q-item-section>
@@ -281,4 +215,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-notifications"> <q-tab-panel name="notifications">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span> <span v-text="$t('notifications_configure')"></span>
@@ -404,14 +404,13 @@
v-text="$t('notification_balance_delta_changed_desc')" v-text="$t('notification_balance_delta_changed_desc')"
></q-item-label> ></q-item-label>
</q-item-section> </q-item-section>
<q-item-section avatar> <q-item-section avatar>
<q-input <q-toggle
class="flow-right" size="md"
type="number" v-model="formData.notification_balance_delta_changed"
min="0" checked-icon="check"
filled color="green"
v-model="formData.notification_balance_delta_threshold_sats" unchecked-icon="clear"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -479,9 +478,7 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_incoming_payment_amount_sats"
formData.lnbits_notification_incoming_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -504,13 +501,11 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_outgoing_payment_amount_sats"
formData.lnbits_notification_outgoing_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,10 +1,9 @@
<template id="lnbits-admin-security"> <q-tab-panel name="security">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<span v-text="$t('server_management')"></span>
</h6>
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('base_url')"></span></p>
<q-input <q-input
filled filled
v-model.number="formData.lnbits_baseurl" v-model.number="formData.lnbits_baseurl"
@@ -16,7 +15,7 @@
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('authentication')"></span> <span v-text="$t('authentication')"></span>
</h6> </h6>
<div class="row q-col-gutter-sm q-mb-md"> <div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-input <q-input
filled filled
@@ -27,16 +26,6 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6">
<q-input
filled
v-model="formData.auth_authentication_cache_minutes"
type="number"
:label="$t('auth_authentication_cache_label')"
:hint="$t('auth_authentication_cache_hint')"
>
</q-input>
</div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-select <q-select
filled filled
@@ -45,10 +34,7 @@
:hint="$t('auth_allowed_methods_hint')" :hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')" :label="$t('auth_allowed_methods_label')"
:options="formData.auth_all_methods" :options="formData.auth_all_methods"
:option-label=" :option-label="option => option.length > 25 ? option.substring(0, 22) + '...' : option"
option =>
option.length > 25 ? option.substring(0, 22) + '...' : option
"
></q-select> ></q-select>
</div> </div>
</div> </div>
@@ -220,11 +206,7 @@
dense dense
flat flat
color="primary" color="primary"
:label=" :label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
serverlogEnabled
? $t('disable_server_log')
: $t('enable_server_log')
"
></q-btn> ></q-btn>
</div> </div>
<br /> <br />
@@ -304,19 +286,12 @@
type="number" type="number"
v-model.number="formData.lnbits_rate_limit_no" v-model.number="formData.lnbits_rate_limit_no"
:label="$t('number_of_requests')" :label="$t('number_of_requests')"
:hint="$t('number_of_requests_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-select <q-select
filled filled
:options="[ :options="[$t('second'),$t('minute'),$t('hour')]"
{label: $t('second'), value: 'second'},
{label: $t('minute'), value: 'minute'},
{label: $t('hour'), value: 'hour'}
]"
emit-value
map-options
v-model="formData.lnbits_rate_limit_unit" v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')" :label="$t('time_unit')"
></q-select> ></q-select>
@@ -362,4 +337,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,48 +1,37 @@
<template id="lnbits-admin-server"> <q-tab-panel name="server">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<span v-text="$t('currency_settings')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('allowed_currencies')"></span></p>
<span v-text="$t('allowed_currencies')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_allowed_currencies" v-model="formData.lnbits_allowed_currencies"
multiple multiple
:hint="$t('allowed_currencies_hint')" :hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')" :label="$t('allowed_currencies')"
:options="g.currencies" :options="{{ currencies | safe }}"
></q-select> ></q-select>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('default_account_currency')"></span></p>
<span v-text="$t('default_account_currency')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_default_accounting_currency" v-model="formData.lnbits_default_accounting_currency"
clearable clearable
:hint="$t('default_account_currency_hint')" :hint="$t('default_account_currency_hint')"
:label="$t('currency')" :label="$t('currency')"
:options=" :options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
formData.lnbits_allowed_currencies?.length
? formData.lnbits_allowed_currencies
: g.allowedCurrencies
"
></q-select> ></q-select>
</div> </div>
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<span v-text="$t('payments')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -54,7 +43,8 @@
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -69,9 +59,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<span v-text="$t('wallet_limiter')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<q-input <q-input
@@ -107,32 +95,33 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<span v-text="$t('service_fees')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
v-model.number="formData.lnbits_service_fee" v-model.number="formData.lnbits_service_fee"
:label="$t('service_fee_label')" :label="$t('service_fee_label')"
step="0.1" step="0.1"
:suffix="'%'" :hint="$t('service_fee_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee_max')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
v-model.number="formData.lnbits_service_fee_max" v-model.number="formData.lnbits_service_fee_max"
:label="$t('service_fee_max_label')" :label="$t('service_fee_max_label')"
:suffix="$t('sats')" :hint="$t('service_fee_max_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('fee_wallet')"></span></p>
<q-input <q-input
filled filled
v-model="formData.lnbits_service_fee_wallet" v-model="formData.lnbits_service_fee_wallet"
@@ -142,9 +131,14 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('disable_fee_internal')"></span></p>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label> <q-item-label v-text="$t('disable_fee')"></q-item-label>
<q-item-label
caption
v-text="$t('disable_fee_internal_desc')"
></q-item-label>
</q-item-section> </q-item-section>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -161,4 +155,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,23 +1,21 @@
<template id="lnbits-admin-site-customisation"> <q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<span v-text="$t('ui_management')"></span>
</h6>
<br /> <br />
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_title')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_site_title" v-model="formData.lnbits_site_title"
:label=" :label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')
"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_tagline')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -26,110 +24,60 @@
></q-input> ></q-input>
<br /> <br />
</div> </div>
</div> <div class="col-12 col-md-2 q-mt-xl">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<q-toggle <q-toggle
:tip="$t('ui_toggle_elements_tip')" :tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements" v-model="formData.lnbits_show_home_page_elements"
:label=" :label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
formData.lnbits_show_home_page_elements
? $t('ui_elements_enable')
: $t('ui_elements_disable')
"
></q-toggle> ></q-toggle>
</div> </div>
</div> </div>
<div>
<p><span v-text="$t('ui_site_description')"></span></p>
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:hint="$t('ui_site_description_hint')"
/>
</div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12">
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:label="$t('ui_site_description')"
:hint="$t('ui_site_description_hint')"
/>
</div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_default_wallet_name" v-model="formData.lnbits_default_wallet_name"
:label="$t('ui_default_wallet_name')" :label="$t('lnbits_wallet')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_qr_logo" v-model="formData.lnbits_qr_logo"
:label="$t('ui_qr_code_logo')" label="https://example.com/image.svg"
:hint="$t('ui_qr_code_logo_hint')" :hint="$t('ui_qr_code_logo_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4">
<q-input
filled
type="text"
v-model="formData.lnbits_apple_touch_icon"
:label="$t('ui_apple_touch_icon')"
:hint="$t('ui_apple_touch_icon_hint')"
></q-input>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<p><span v-text="$t('wallet_featured_button_title')"></span></p>
</div>
<div class="col-12 col-md-4">
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_label"
:label="$t('wallet_featured_button_label')"
:hint="$t('wallet_featured_button_label_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_url"
:label="$t('wallet_featured_button_url')"
:hint="$t('wallet_featured_button_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_icon"
label="bolt"
:hint="$t('wallet_featured_button_icon_hint')"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-md q-mt-md"> <div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-12"> <div class="col-12 col-md-6">
<h6 class="q-my-sm"> <p><span v-text="$t('ui_custom_badge')"></span></p>
<span v-text="$t('ui_custom_badge_title')"></span>
</h6>
<p class="q-mb-none">
<span v-text="$t('ui_custom_badge_desc')"></span>
</p>
</div>
<div class="col-12">
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-8">
<q-input <q-input
filled filled
type="text" type="text"
tip="Custom Badge" tip="Custom Badge"
v-model.trim="formData.lnbits_custom_badge" v-model.trim="formData.lnbits_custom_badge"
:label="$t('ui_custom_badge')" :label="$t('ui_custom_badge_label')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-4">
<q-select <q-select
filled filled
v-model="formData.lnbits_custom_badge_color" v-model="formData.lnbits_custom_badge_color"
@@ -139,8 +87,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12 col-md-6">
<h6 class="q-my-sm"><span v-text="$t('ui_custom_image')"></span></h6> <p><span v-text="$t('ui_custom_image')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -151,8 +99,10 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('themes')"></span></p>
<q-select <q-select
filled filled
v-model="formData.lnbits_theme_options" v-model="formData.lnbits_theme_options"
@@ -164,57 +114,46 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('custom_logo')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_custom_logo" v-model="formData.lnbits_custom_logo"
:label="$t('custom_logo')" label="https://example.com/image.png"
:hint="$t('custom_logo_hint')" :hint="$t('custom_logo_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<h6 class="q-my-sm">
<span v-text="$t('ad_space_section_title')"></span>
</h6>
<p class="q-my-none">
<span v-text="$t('ad_space_section_desc')"></span>
</p>
</div>
<div class="col-12 col-md-12">
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
:label="
formData.lnbits_ad_space_enabled
? $t('ads_enabled')
: $t('ads_disabled')
"
/>
</div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('ad_space_title')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_ad_space_title" v-model="formData.lnbits_ad_space_title"
:label="$t('ad_space_title')" :label="$t('ad_space_title_label')"
:hint="$t('ad_space_title_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('ad_slots')"></span></p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
v-model="formData.lnbits_ad_space" v-model="formData.lnbits_ad_space"
type="text" type="text"
:label="$t('ad_slots')" :label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')" :hint="$t('ad_slots_hint')"
><q-tooltip> ><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}" format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip> </q-tooltip>
</q-input> </q-input>
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
/>
<br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md q-my-md"> <div class="row q-col-gutter-md q-my-md">
@@ -256,54 +195,16 @@
> >
</q-input> </q-input>
</div> </div>
</div> <div class="col-12 col-sm-6 col-lg-3">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle <q-toggle
type="bool" type="bool"
v-model="formData.lnbits_default_gradient" v-model="formData.lnbits_default_gradient"
color="primary" color="primary"
:label="$t('gradient_background')" label="Gradient Background"
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_dark"
color="primary"
:label="$t('toggle_darkmode')"
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_card_rounded"
color="primary"
:label="$t('rounded_ui')"
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_card_gradient"
color="primary"
:label="$t('card_gradient')"
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_card_shadow"
color="primary"
:label="$t('card_shadow')"
> >
</q-toggle> </q-toggle>
</div> </div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -0,0 +1,83 @@
<q-tab-panel name="users">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span>
</h6>
<div class="row">
<div class="col-12 col-md-6 q-pr-sm">
<p><span v-text="$t('admin_users')"></span></p>
<q-input
filled
v-model="formAddAdmin"
@keydown.enter="addAdminUser"
type="text"
:label="$t('admin_users_label')"
:hint="$t('admin_users_hint')"
>
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.lnbits_admin_users"
:key="user"
removable
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p>
<q-input
filled
v-model="formAddUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint')"
>
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.lnbits_allowed_users"
:key="user"
removable
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
<q-item-label
caption
v-text="$t('allow_creation_user_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_allow_new_accounts"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
+302
View File
@@ -0,0 +1,302 @@
{% 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 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
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<!-- Mobile: Horizontal tabs at top -->
<q-tabs
v-if="$q.screen.lt.md"
@update:model-value="showExchangeProvidersTab"
v-model="tab"
dense
active-color="primary"
inline-label
class="text-primary"
>
<q-tab name="funding" icon="account_balance_wallet" label="Fund" />
<q-tab name="security" icon="security" label="Sec" />
<q-tab name="server" icon="settings" label="Srv" />
<q-tab name="exchange_providers" icon="swap_horiz" label="Exch" />
<q-tab name="fiat_providers" icon="account_balance" label="Fiat" />
<q-tab name="extensions" icon="extension" label="Ext" />
<q-tab name="notifications" icon="notifications" label="Not" />
<q-tab name="audit" icon="receipt_long" label="Aud" />
<q-tab name="site_customisation" icon="language" label="Site" />
<q-tab name="library" icon="image" label="Lib" />
</q-tabs>
<!-- Desktop: Vertical sidebar with splitter -->
<q-splitter v-if="$q.screen.gt.sm">
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@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="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
<!-- Mobile: Full-width content without sidebar -->
<q-form
v-if="$q.screen.lt.md"
name="settings_form"
id="settings_form_mobile"
>
<q-scroll-area style="height: 70vh">
<q-tab-panels
v-model="tab"
animated
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
style="overflow-x: hidden; padding: 8px"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</q-card>
</div>
</div>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% 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 q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<!-- Optional: Add content here if needed -->
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#audit"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+305
View File
@@ -0,0 +1,305 @@
<q-expansion-item
group="extras"
icon="vpn_key"
:label="$t('api_keys_api_docs')"
:content-inset-level="0.5"
>
<q-card-section>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,830 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row">
<div class="col-12">
<q-stepper
v-model="step"
ref="stepper"
color="primary"
animated
header-nav
class="q-pt-sm"
@update:model-value="onStepChange"
>
<q-step
:name="1"
title="Describe"
icon="info"
:done="step > 1"
style="min-height: 100px"
>
<div class="row q-col-gutter-md">
<div class="col-12">
<span class="text-h6">
Tell us something about your extension:
</span>
<ul>
<li>This is the first step, you can return and change it.</li>
<li>
The <code>`name`</code> and
<code>`sort description`</code> fields are what the users will
see when browsing the list of extensions.
</li>
<li>
The <code>`id`</code> field is used internally and in the URL of
your extension.
</li>
</ul>
</div>
<!-- todo: add icon -->
<div class="col-12">
<div>
<q-btn
color="primary"
label="Upload Existing config"
@click="$refs.extensionDataInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="extensionDataInput"
accept="application/json"
style="display: none"
@change="onJsonDataInput"
/>
</div>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.name"
label="Extension Name"
hint="The name of your extension"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.id"
label="Extension Id"
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.short_description"
label="Short Description"
hint="A short description that is shown in the extension list."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
v-model="extensionData.description"
label="Description"
hint="A detailed description of your extension."
type="textarea"
rows="3"
maxlength="1000"
>
</q-input>
</div>
</div>
</q-step>
<q-step
:name="2"
title="Settings"
icon="settings"
:done="step > 2"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep2"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('settings')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.settings_data.enabled"
label="Generate Settings Fields"
size="md"
color="green"
/>
<br />
<ul>
<li>Define what settings your extension will have.</li>
<li>
You can choose if each user has its own settings or if the
settings are global (set by the admin).
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.settings_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-md-2 col-sm-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.settings_data.type"
:options="settingsTypes"
></q-select>
</div>
<div class="col-md-10 col-sm-12 q-pt-sm">
<q-badge
v-if="extensionData.settings_data.type === 'user'"
outline
class="text-caption q-ml-md"
>Each user can set its own settings for this extension.</q-badge
>
<q-badge v-else outline class="text-caption q-ml-md"
>Settings are set by the admin and apply to all users of the
extension</q-badge
>
</div>
</div>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.settings_data.fields"
:hide-advanced="true"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="3"
:done="step > 3"
title="Owner Data"
icon="list"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep3"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('owner_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-input
v-model="extensionData.owner_data.name"
filled
label="Owner Table Name"
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
The owner of the extension manages this data. It can add, remove
and update instances of it.
</li>
<li>
Some fileds are present by default, like
<code>created_at</code>, <code>updated_at</code> and
<code>extra</code>.
</li>
</ul>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.owner_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="4"
:done="step > 4"
title="Client Data"
icon="blur_linear"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep4"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('client_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<!-- <q-toggle
v-model="extensionData.client_data.enabled"
label="Generate Client Table"
disable
size="md"
color="green"
/>
<br /> -->
<q-input
v-if="extensionData.client_data.enabled"
v-model="extensionData.client_data.name"
filled
label="Client Table Name"
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
This data is created by users of the extension. Usually when
they submit a form or make a payment.
</li>
<li>
The owner of the extension can view this data, but should not
modify it.
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.client_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.client_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="5"
:done="step > 5"
title="Public Pages"
icon="link"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep5"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('public_page')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.public_page.has_public_page"
label="Generate Public Page"
size="md"
color="green"
/>
<br />
<ul>
<li>
Most extensions have a public page that can be shared (this page
will still be accessible even if you have restricted access to
your LNbits install).
</li>
</ul>
</div>
</div>
<div v-if="extensionData.public_page.has_public_page">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page title</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}
@@ -0,0 +1,150 @@
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request(
'PUT',
'/api/v1/auth/first_install',
null,
{
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
}
)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
+710
View File
@@ -0,0 +1,710 @@
{% extends "public.html" %} {% block scripts %}
<style>
:root {
--size: 100px;
--gap: 25px;
}
.btn-fixed-width {
/* width: 45%; */
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(
to right,
hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0)
);
}
.marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.marquee:hover .marquee__group {
animation-play-state: paused;
}
.marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
</style>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div
class="row justify-center items-center"
style="min-height: calc(100vh / 1.618)"
>
<div
class="full-width"
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
>
<div class="row q-mb-md">
<div class="col-12">
<div>
<h5
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}}
</h5>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div> -->
</div>
</div>
</div>
<div class="row">
<q-badge
v-if="isAccessTokenExpired"
class="q-mx-auto q-mb-md"
color="primary"
rounded
>
<div class="text-h6">
<span v-text="$t('session_has_expired')"></span>
</div>
</q-badge>
<q-card bordered class="full-width q-py-md">
<div class="row">
<div
class="col-12"
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
in LNBITS_AUTH_METHODS)%}
<div class="full-height content-center">
<q-card-section>
<div class="text-body1">
<span v-text="$t('claim_desc')"></span>
</div>
</q-card-section>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
</q-card-section>
</div>
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="LNBITS_AUTH_METHODS"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:reset-key="reset_key"
@login="login"
@register="register"
@reset="reset"
>
<div
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered?
<a
href="#"
class="text-secondary cursor-pointer"
@click.prevent="showRegister('username-password')"
>Create an Account</a
>
</p>
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span>
<a
href="#"
class="text-secondary cursor-pointer q-ml-sm"
@click.prevent="showLogin('username-password')"
v-text="$t('login')"
></a>
</p>
</div>
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
:auth-method="authMethod"
@show-login="showLogin"
@show-register="showRegister"
@login-usr="loginUsr"
@create-wallet="createWallet"
>
</user-id-only>
{%endif%} {% endif %}
</div>
<div
class="col-sm-5 col-lg-6 gt-xs"
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
>
<div class="full-height flex flex-center q-pa-lg">
<q-img
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class=""
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class=""
></q-btn>
</div>
</div>
{% if AD_SPACE_ENABLED and AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
</div>
<div class="flex flex-center columm">
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column q-pr-sm">
<a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
class="full-width"
/>
<img
v-else
src="{{ AD[2] }}"
style="max-width: 420px"
class="full-width"
/>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
{% 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 q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}
-3
View File
@@ -1,3 +0,0 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}
@@ -0,0 +1,379 @@
<q-tab-panel name="channels">
<q-dialog v-model="connectPeerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="connectPeerDialog.data.uri"
label="Node URI"
hint="pubkey@host:port"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('connect')"
color="primary"
@click="connectPeer"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="setFeeDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<label class="text-h6">Set Channel Fee</label>
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
<q-separator></q-separator>
<q-form class="q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_ppm"
label="Fee Rate PPM"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_base_msat"
label="Fee Base msat"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('set')"
color="primary"
@click="setChannelFee(setFeeDialog.channel_id)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="openChannelDialog.data.peer_id"
label="Peer ID"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.funding_amount"
label="Funding Amount"
></q-input>
<q-expansion-item icon="warning" label="Advanced">
<q-card>
<q-card-section>
<div class="column q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.push_amount"
label="Push Amount"
hint="This gifts sats to the other side!"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.fee_rate"
label="Fee Rate"
></q-input>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
:label="$t('open')"
color="primary"
@click="openChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="closeChannelDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<div>
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('close')"
color="primary"
@click="closeChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-card-section class="q-pa-none">
<div class="row q-col-gutter-lg">
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="q-gutter-y-sm">
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
<q-input
filled
dense
clearable
v-model="channels.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-select
dense
size="sm"
style="min-width: 200px"
filled
multiple
clearable
v-model="stateFilters"
:options="this.states"
class="col-auto"
></q-select>
<q-btn
unelevated
color="primary"
size="md"
class="col-auto"
@click="showOpenChannelDialog()"
>
Open channel
</q-btn>
</div>
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
:balance="this.totalBalance"
></lnbits-channel-balance>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="props.row.name"></div>
<div class="text-caption" v-if="props.row.peer_id">
<span>Peer ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.peer_id)"
></q-btn>
</div>
<div class="text-caption col-grow">
<span>Fees</span>
<q-btn
size="xs"
flat
dense
icon="settings"
@click="showSetFeeDialog(props.row.id)"
></q-btn>
<span v-if="props.row.fee_ppm">
<span v-text="props.row.fee_ppm"></span> ppm,
<span v-text="props.row.fee_base_msat"></span> msat
</span>
</div>
<div class="text-caption" v-if="props.row.id">
<span>Channel ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
</div>
<div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.short_id)"
></q-btn>
</div>
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
>
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
flat
dense
size="md"
@click="showCloseChannelDialog(props.row)"
icon="cancel"
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
></lnbits-channel-balance>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="column q-gutter-y-sm">
<div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
>
<div class="col-grow text-h6 q-my-none">Peers</div>
<q-input
filled
dense
clearable
v-model="peers.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-btn
class="col-auto"
color="primary"
@click="connectPeerDialog.show = true"
>
Connect Peer
</q-btn>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow">
<div
class="text-subtitle1 text-bold"
v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
v-text="'#'+props.row.color"
>
</q-badge>
<div
class="text-bold"
v-text="shortenNodeId(props.row.id)"
></div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row)"
></q-btn>
</div>
</div>
<q-btn
unelevated
color="primary"
@click="showOpenChannelDialog(props.row.id)"
>
Open channel
</q-btn>
<q-btn
flat
dense
size="md"
@click="disconnectPeer(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,68 @@
<q-tab-panel name="dashboard">
<q-card-section class="q-pa-none">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Fees collected"
:msat="this.info.fees?.total_msat"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Balance"
:btc="this.info.onchain_balance_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Confirmed"
:btc="this.info.onchain_confirmed_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats
:stats="this.channel_stats"
></lnbits-channel-stats>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,314 @@
<q-tab-panel name="transactions">
<q-card-section class="q-pa-none">
<q-dialog v-model="transactionDetailsDialog.show" position="top">
<q-card class="my-card">
<q-card-section>
<div class="text-center q-mb-lg">
<div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
>
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
</div>
<div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono">
<span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div
class="row"
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
<span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
</div>
</div>
<div
v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(transactionDetailsDialog.data.bolt11)"
:label="$t('copy_invoice')"
class="q-mt-sm"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<div class="row q-col-gutter-md q-pb-lg"></div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Payments</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@request="getPayments"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_made"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-dialog
v-model="props.row.expand"
:props="props"
position="top"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-td>
</template>
<template v-slot:body-cell-date="props">
<q-td auto-width key="date" :props="props">
<lnbits-date :ts="props.row.time"></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-destination="props">
<q-td auto-width key="destination">
<div class="row items-center justify-between no-wrap">
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
v-text="props.row.destination?.alias"
></q-badge>
<div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(info.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row.destination)"
></q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Invoices</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_received"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-paid_at="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.paid_at"
:ts="props.row.paid_at"
></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-expiry="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.expiry"
:ts="props.row.expiry"
></lnbits-date>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
+46
View File
@@ -0,0 +1,46 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<q-dialog v-model="nodeInfoDialog.show" position="top">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
</q-dialog>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="dashboard"
:label="$t('dashboard')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="channels"
:label="$t('channels')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="transactions"
:label="$t('transactions')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "node/_tab_dashboard.html" %} {% include
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
%}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
{% endblock %}
+133
View File
@@ -0,0 +1,133 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: [],
activeBalance: {},
ranks: {},
peers: [],
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, data) {
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
},
getInfo: function () {
this.api('GET', '/info', {}).then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
this.api('GET', '/rank', {}).then(response => {
this.ranks = response.data
})
}
}
})
</script>
{% endblock %}
+432
View File
@@ -0,0 +1,432 @@
{% 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 q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col"></div>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentStatus"
:label="$t('payments_status_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentTags"
:label="$t('payments_tag_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalance"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showWalletsSize"
:label="$t('payments_wallets_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<q-btn icon="event" outline flat>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
/>
</q-btn>
<q-separator vertical class="q-ma-sm"></q-separator>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#server"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div v-show="!showDetails">
<div class="row q-col-gutter-md justify-center q-mb-md">
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_status')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tags')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalance"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showWalletsSize"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalanceInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_balance_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentCountInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_count_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="payment_hash"
:rows="payments"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
:filter="paymentsTable.search"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchPaymentsBy()"
@update:model-value="searchPaymentsBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchPaymentsBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-btn
v-else-if="['status'].includes(col.name)"
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'status'">
<q-tooltip
><span v-text="$t('payment_details')"></span
></q-tooltip>
<q-icon
@click="showDetailsToggle(props.row)"
v-if="props.row.status === 'success'"
size="14px"
:name="props.row.outgoing ? 'call_made' : 'call_received'"
:color="props.row.outgoing ? 'pink' : 'green'"
class="cursor-pointer"
></q-icon>
<q-icon
v-else-if="props.row.status === 'pending'"
@click="showDetailsToggle(props.row)"
name="downloading"
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
color="grey"
class="cursor-pointer"
></q-icon>
<q-icon
v-else
@click="showDetailsToggle(props.row)"
name="warning"
color="yellow"
class="cursor-pointer"
></q-icon>
</div>
<div v-else-if="col.name == 'created_at'">
<div>
<q-tooltip anchor="top middle">
<span v-text="formatDate(props.row.created_at)"></span>
</q-tooltip>
<span v-text="props.row.timeFrom"> </span>
</div>
</div>
<div
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip anchor="top middle">Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name], col.max_length)">
</span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
<div v-show="showDetails">
<q-card>
<q-card-section class="flex">
<div>
<q-btn
flat
round
icon="arrow_back"
class="q-mr-md"
@click="showDetailsToggle(null)"
></q-btn>
</div>
<div class="self-center text-h6 text-weight-bolder text-grey-5">
<span v-text="$t('payment_details_back')"></span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-h6">
<q-item>
<q-item-section avatar class="">
<q-icon color="primary" name="receipt" size="44px"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="text-h6">
<span v-text="$t('payment_details')"></span>
</div>
</q-item-label>
<q-item-label caption v-text="$t('payment_details_desc')">
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="(value, key) in paymentDetails" :key="key">
<q-item-section>
<q-item-label v-text="key"></q-item-label>
<q-item-label
caption
v-text="value"
style="word-wrap: break-word"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-show="value"
icon="content_copy"
flat
class="cursor-pointer q-ml-sm"
@click="copyText(value)"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</q-item-section>
<!-- <q-separator></q-separator> -->
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
{% endblock %}
@@ -0,0 +1,44 @@
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<div class="row">
<div class="col-12">
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
</q-card>
</q-dialog>
@@ -0,0 +1,196 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="'Nostr '+ $t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>
@@ -0,0 +1,190 @@
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width v-if="g.user.super_user"></q-th>
<q-th auto-width></q-th>
<q-th
auto-width
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
size="sm"
color="secondary"
@click="showPayments(props.row.id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<span
v-text="props.row.name"
v-if="!props.row.editable"
:class="props.row.deleted ? 'text-strike' : 'cursor-pointer'"
@click="props.row.editable = true && !props.row.deleted"
></span>
<q-input
v-else
@keydown.enter="updateWallet(props.row)"
v-model="props.row.name"
size="xs"
flat
dense
>
<template v-slot:append>
<q-btn
@click="updateWallet(props.row)"
round
dense
size="xs"
flat
icon="send"
/>
</template>
</q-input>
</q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
+186
View File
@@ -0,0 +1,186 @@
{% 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 q-col-gutter-md justify-center">
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<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="showAccountPage()"
:label="$t('create_account')"
color="primary"
>
</q-btn>
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#users"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="users"
:columns="usersTable.columns"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@request="fetchUsers"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
>
<q-tooltip>Copy Public Key</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
{% endblock %}
+108 -16
View File
@@ -1,15 +1,20 @@
import os import os
import time import time
from http import HTTPStatus from http import HTTPStatus
from shutil import make_archive from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import APIRouter, Depends, File import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import ( from lnbits.core.services import (
enqueue_admin_notification, enqueue_admin_notification,
get_balance_delta, get_balance_delta,
@@ -18,6 +23,7 @@ from lnbits.core.services import (
from lnbits.core.services.notifications import send_email_notification from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners from lnbits.tasks import invoice_listeners
@@ -67,9 +73,9 @@ async def api_test_email():
@admin_router.get("/api/v1/settings") @admin_router.get("/api/v1/settings")
async def api_get_settings( async def api_get_settings(
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> AdminSettings | None: ) -> AdminSettings | None:
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
return admin_settings return admin_settings
@@ -77,14 +83,12 @@ async def api_get_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings( async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
data: UpdateSettings, account: Account = Depends(check_admin)
):
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
if not admin_settings: if not admin_settings:
raise ValueError("Updated admin settings not found.") raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict()) update_cached_settings(admin_settings.dict())
@@ -96,11 +100,9 @@ async def api_update_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings_partial( async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data}) updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, account) return await api_update_settings(updatable_settings, user)
@admin_router.get( @admin_router.get(
@@ -114,9 +116,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK) @admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(account: Account = Depends(check_super_user)) -> None: async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await reset_core_settings() await reset_core_settings()
server_restart.set() server_restart.set()
@@ -170,3 +172,93 @@ async def api_download_backup() -> FileResponse:
return FileResponse( return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip" path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
) )
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+6 -11
View File
@@ -8,17 +8,13 @@ from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet,
ConversionData, ConversionData,
CreateWallet, CreateWallet,
User, User,
Wallet, Wallet,
) )
from lnbits.core.models.users import AccountId from lnbits.decorators import check_user_exists
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_user_exists,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
@@ -43,9 +39,7 @@ async def health() -> dict:
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK) @api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check( async def health_check(user: User = Depends(check_user_exists)) -> dict:
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
stat: dict[str, Any] = { stat: dict[str, Any] = {
"server_time": int(time()), "server_time": int(time()),
"up_time": settings.lnbits_server_up_time, "up_time": settings.lnbits_server_up_time,
@@ -53,7 +47,7 @@ async def health_check(
} }
stat["version"] = settings.version stat["version"] = settings.version
if not account_id.is_admin_id: if not user.admin:
return stat return stat
funding_source = get_funding_source() funding_source = get_funding_source()
@@ -70,6 +64,7 @@ async def health_check(
"/api/v1/wallets", "/api/v1/wallets",
name="Wallets", name="Wallets",
description="Get basic info for all of user's wallets.", description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
) )
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]: async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets return user.wallets
@@ -83,7 +78,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
@api_router.get( @api_router.get(
"/api/v1/rate/history", "/api/v1/rate/history",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def api_exchange_rate_history() -> list[dict]: async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history return settings.lnbits_exchange_rate_history
-174
View File
@@ -1,174 +0,0 @@
import base64
from http import HTTPStatus
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from lnbits.core.crud.assets import (
delete_user_asset,
get_asset_info,
get_public_asset,
get_public_asset_info,
get_user_asset,
get_user_asset_info,
get_user_assets,
update_user_asset_info,
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
optional_user_id,
parse_filters,
)
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
upload_file_param = File(...)
@asset_router.get(
"/paginated",
name="Get user assets",
summary="Get paginated list user assets",
)
async def api_get_user_assets(
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(account_id.id, filters=filters)
@asset_router.get(
"/{asset_id}",
name="Get user asset",
summary="Get user asset by ID",
)
async def api_get_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info
@asset_router.get(
"/{asset_id}/data",
name="Get user asset data",
summary="Get user asset data data by ID",
)
async def api_get_asset_data(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset = None
if user_id:
asset = await get_user_asset(user_id, asset_id)
if not asset:
asset = await get_public_asset(asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
"/{asset_id}/thumbnail",
name="Get user asset thumbnail",
summary="Get user asset thumbnail data by ID",
)
async def api_get_asset_thumbnail(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset_info = None
if user_id:
asset_info = await get_user_asset_info(user_id, asset_id)
if not asset_info:
asset_info = await get_public_asset_info(asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@asset_router.put(
"/{asset_id}",
name="Update user asset",
summary="Update user asset by ID",
)
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
if account_id.is_admin_id:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
asset_info.name = data.name or asset_info.name
asset_info.is_public = (
asset_info.is_public if data.is_public is None else data.is_public
)
await update_user_asset_info(asset_info)
return asset_info
@asset_router.post(
"",
name="Upload",
summary="Upload user assets",
)
async def api_upload_asset(
account_id: AccountId = Depends(check_account_id_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(account_id.id, file, public_asset)
asset_info = await get_user_asset_info(account_id.id, asset.id)
if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.")
return asset_info
@asset_router.delete(
"/{asset_id}",
name="Delete user asset",
summary="Delete user asset by ID",
)
async def api_delete_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
asset = await get_user_asset(account_id.id, asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
+54 -136
View File
@@ -4,15 +4,13 @@ import json
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
from time import time from time import time
from typing import Annotated
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.crud.users import ( from lnbits.core.crud.users import (
get_user_access_control_lists, get_user_access_control_lists,
update_user_access_control_list, update_user_access_control_list,
@@ -27,16 +25,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList, UpdateAccessControlList,
) )
from lnbits.core.services import create_user_account from lnbits.core.services import create_user_account
from lnbits.core.services.users import ( from lnbits.decorators import access_token_payload, check_user_exists
check_register_activation_settings,
update_user_account,
)
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
decrypt_internal_message, decrypt_internal_message,
@@ -90,7 +79,6 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username) account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@@ -99,7 +87,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98): if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request) event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"], active_only=False) account = await get_account_by_pubkey(event["pubkey"])
if not account: if not account:
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
@@ -107,8 +95,6 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"), extra=UserExtra(provider="nostr"),
) )
await create_user_account(account) await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email) return _auth_success_response(account.username or "", account.id, account.email)
@@ -129,71 +115,14 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl") @auth_router.get("/acl")
async def api_get_user_acls( async def api_get_user_acls(
request: Request, request: Request,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
api_routes = get_api_routes(request.app.router.routes) api_routes = get_api_routes(request.app.router.routes)
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
# Add missing/new endpoints to the ACLs # Add missing/new endpoints to the ACLs
for acl in acls.access_control_list: for acl in acls.access_control_list:
@@ -206,7 +135,7 @@ async def api_get_user_acls(
acl.endpoints.append(EndpointAccess(path=path, name=name)) acl.endpoints.append(EndpointAccess(path=path, name=name))
acl.endpoints.sort(key=lambda e: e.name.lower()) acl.endpoints.sort(key=lambda e: e.name.lower())
return UserAcls(id=account.id, access_control_list=acls.access_control_list) return UserAcls(id=user.id, access_control_list=acls.access_control_list)
@auth_router.put("/acl") @auth_router.put("/acl")
@@ -214,13 +143,13 @@ async def api_get_user_acls(
async def api_update_user_acl( async def api_update_user_acl(
request: Request, request: Request,
data: UpdateAccessControlList, data: UpdateAccessControlList,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
acl = user_acls.get_acl_by_id(data.id) acl = user_acls.get_acl_by_id(data.id)
if acl: if acl:
user_acls.access_control_list.remove(acl) user_acls.access_control_list.remove(acl)
@@ -245,30 +174,33 @@ async def api_update_user_acl(
@auth_router.delete("/acl") @auth_router.delete("/acl")
async def api_delete_user_acl( async def api_delete_user_acl(
data: DeleteAccessControlList, account: Account = Depends(check_account_exists) data: DeleteAccessControlList,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
user_acls.delete_acl_by_id(data.id) user_acls.delete_acl_by_id(data.id)
await update_user_access_control_list(user_acls) await update_user_access_control_list(user_acls)
@auth_router.post("/acl/token") @auth_router.post("/acl/token")
async def api_create_user_api_token( async def api_create_user_api_token(
data: ApiTokenRequest, account: Account = Depends(check_account_exists) data: ApiTokenRequest,
user: User = Depends(check_user_exists),
) -> ApiTokenResponse: ) -> ApiTokenResponse:
if not data.expiration_time_minutes > 0: if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.") raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -285,16 +217,18 @@ async def api_create_user_api_token(
@auth_router.delete("/acl/token") @auth_router.delete("/acl/token")
async def api_delete_user_api_token( async def api_delete_user_api_token(
data: DeleteTokenRequest, account: Account = Depends(check_account_exists) data: DeleteTokenRequest,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -364,14 +298,12 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username): if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username, active_only=False): if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
await check_register_activation_settings(data)
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
email=data.email, email=data.email,
@@ -385,20 +317,24 @@ async def register(data: RegisterUser) -> JSONResponse:
@auth_router.put("/pubkey") @auth_router.put("/pubkey")
async def update_pubkey( async def update_pubkey(
data: UpdateUserPubkey, data: UpdateUserPubkey,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if ( if (
data.pubkey data.pubkey
and data.pubkey != account.pubkey and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey) and await get_account_by_pubkey(data.pubkey)
): ):
raise ValueError("Public key already in use.") raise ValueError("Public key already in use.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey) account.pubkey = normalize_public_key(data.pubkey)
await update_account(account) await update_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)
@@ -407,19 +343,23 @@ async def update_pubkey(
@auth_router.put("/password") @auth_router.put("/password")
async def update_password( async def update_password(
data: UpdateUserPassword, data: UpdateUserPassword,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
if ( if (
data.username data.username
and account.username != data.username and user.username != data.username
and await get_account_by_username(data.username) and await get_account_by_username(data.username)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise ValueError("Account not found.")
# old accounts do not have a password # old accounts do not have a password
if account.password_hash: if account.password_hash:
if not data.password_old: if not data.password_old:
@@ -476,70 +416,48 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email) return _auth_success_response(account.username, user_id, account.email)
@auth_router.patch("") @auth_router.put("/update")
async def update( async def update(
data: UpdateUser, account: Account = Depends(check_account_exists) data: UpdateUser, user: User = Depends(check_user_exists)
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username: if data.username:
account.username = data.username account.username = data.username
if data.extra: if data.extra:
account.extra = data.extra account.extra = data.extra
await update_user_account(account) await update_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)
@auth_router.patch("/ui")
async def update_ui_customization(
req: Request, account: Account = Depends(check_account_exists)
) -> Account:
ui_customization = await req.json()
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
if len(account.ui_customization or {}) > 1000 * 1024:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
)
await update_user_account(account)
return account
@auth_router.put("/first_install") @auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install: if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install") raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token:
if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account_by_username(data.username, False)
if account:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(settings.super_user) account = await get_account(settings.super_user)
if not account: if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.") raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username account.username = data.username
account.extra = account.extra or UserExtra() account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits" account.extra.provider = "lnbits"
account.hash_password(data.password) account.hash_password(data.password)
await update_account(account) await update_account(account)
settings.first_install = False settings.first_install = False
# only confrm it after the super user has been successfully updated
if settings.first_install_token:
settings.first_install_token_confirmed = data.first_install_token
await set_settings_field(
"first_install_token_confirmed", data.first_install_token
)
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@@ -549,7 +467,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet" redirect_path = "/wallet"
account = await get_account_by_email(email, active_only=False) account = await get_account_by_email(email)
if verified_user_id: if verified_user_id:
if account: if account:
+7 -144
View File
@@ -9,9 +9,7 @@ from lnbits.core.crud.payments import (
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook,
) )
from lnbits.core.services.payments import create_fiat_invoice from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@@ -24,7 +22,7 @@ callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
async def api_generic_webhook_handler( async def api_generic_webhook_handler(
provider_name: str, request: Request provider_name: str, request: Request
) -> SimpleStatus: ) -> SimpleStatus:
logger.info(f"Received callback from provider: '{provider_name}'.")
if provider_name.lower() == "stripe": if provider_name.lower() == "stripe":
payload = await request.body() payload = await request.body()
sig_header = request.headers.get("Stripe-Signature") sig_header = request.headers.get("Stripe-Signature")
@@ -39,17 +37,6 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "paypal":
payload = await request.body()
await verify_paypal_webhook(request.headers, payload)
event = await request.json()
await handle_paypal_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@@ -61,8 +48,6 @@ async def handle_stripe_event(event: dict):
event_type = event.get("type") event_type = event.get("type")
if event_type == "checkout.session.completed": if event_type == "checkout.session.completed":
await _handle_stripe_checkout_session_completed(event) await _handle_stripe_checkout_session_completed(event)
elif event_type == "payment_intent.succeeded":
await _handle_stripe_intent_session_completed(event)
elif event_type == "invoice.paid": elif event_type == "invoice.paid":
await _handle_stripe_subscription_invoice_paid(event) await _handle_stripe_subscription_invoice_paid(event)
else: else:
@@ -71,38 +56,18 @@ async def handle_stripe_event(event: dict):
) )
async def _handle_stripe_intent_session_completed(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await check_fiat_status(payment)
async def _handle_stripe_checkout_session_completed(event: dict): async def _handle_stripe_checkout_session_completed(event: dict):
event_id = event.get("id") event_id = event.get("id")
event_object = event.get("data", {}).get("object", {}) event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object") object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash") payment_hash = event_object.get("metadata", {}).get("payment_hash")
alan_action = event_object.get("metadata", {}).get("alan_action") lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
logger.debug( logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'." f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'." f" Payment hash: '{payment_hash}'."
) )
if alan_action != "invoice": if lnbits_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.") logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
return return
if not payment_hash: if not payment_hash:
@@ -111,7 +76,7 @@ async def _handle_stripe_checkout_session_completed(event: dict):
payment = await get_standalone_payment(payment_hash) payment = await get_standalone_payment(payment_hash)
if not payment: if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.") raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await check_fiat_status(payment) await payment.check_fiat_status()
async def _handle_stripe_subscription_invoice_paid(event: dict): async def _handle_stripe_subscription_invoice_paid(event: dict):
@@ -156,7 +121,7 @@ async def _handle_stripe_subscription_invoice_paid(event: dict):
), ),
) )
await check_fiat_status(payment) await payment.check_fiat_status()
async def _get_stripe_subscription_payment_options( async def _get_stripe_subscription_payment_options(
@@ -167,7 +132,7 @@ async def _get_stripe_subscription_payment_options(
metadata = parent.get("subscription_details", {}).get("metadata", {}) metadata = parent.get("subscription_details", {}).get("metadata", {})
if metadata.get("alan_action") != "subscription": if metadata.get("lnbits_action") != "subscription":
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.") raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
if "extra" in metadata: if "extra" in metadata:
@@ -178,105 +143,3 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {} metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata) return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_id = event.get("id", "")
event_type = event.get("event_type", "")
resource = event.get("resource", {})
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await check_fiat_status(payment)
return
if event_type in ("PAYMENT.SALE.COMPLETED"):
await _handle_paypal_subscription_payment(resource)
return
logger.warning(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency") or "").upper()
total = amount_info.get("total")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
custom_id = resource.get("custom_id") or resource.get("custom")
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
payment_options = _deserialize_paypal_metadata(custom_id)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"subscription_request_id": resource.get("billing_agreement_id"),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
"payment_request": "",
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=float(total),
memo=memo,
extra=extra,
fiat_provider="paypal",
),
)
await check_fiat_status(payment)
def _paypal_extract_payment_hash(resource: dict) -> str | None:
purchase_units = resource.get("purchase_units") or []
for pu in purchase_units:
if pu.get("invoice_id"):
return pu.get("invoice_id")
if pu.get("custom_id"):
return pu.get("custom_id")
return None
def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions()
+22 -172
View File
@@ -2,35 +2,27 @@ import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
CreateExtension, CreateExtension,
CreateExtensionReview,
Extension, Extension,
ExtensionConfig, ExtensionConfig,
ExtensionMeta, ExtensionMeta,
ExtensionRelease, ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension, InstallableExtension,
PayToEnableInfo, PayToEnableInfo,
ReleasePaymentInfo, ReleasePaymentInfo,
UserExtension, UserExtension,
UserExtensionInfo, UserExtensionInfo,
) )
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
@@ -40,20 +32,16 @@ from lnbits.core.services.extensions import (
install_extension, install_extension,
uninstall_extension, uninstall_extension,
) )
from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_admin, check_admin,
check_user_exists,
) )
from lnbits.settings import settings
from ..crud import ( from ..crud import (
create_user_extension, create_user_extension,
delete_dbversion, delete_dbversion,
drop_extension_db, drop_extension_db,
get_db_version, get_db_version,
get_db_versions,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_user_extension, get_user_extension,
@@ -152,10 +140,9 @@ async def api_extension_details(
async def api_update_pay_to_enable( async def api_update_pay_to_enable(
ext_id: str, ext_id: str,
data: PayToEnableInfo, data: PayToEnableInfo,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
user_wallet_ids = await get_wallets_ids(account.id, deleted=False) if data.wallet not in user.wallet_ids:
if data.wallet not in user_wallet_ids:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user." HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
) )
@@ -174,7 +161,7 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
@@ -188,12 +175,12 @@ async def api_enable_extension(
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
if account_id.is_admin_id or not ext.requires_payment: if user.admin or not ext.requires_payment:
user_ext.active = True user_ext.active = True
await update_user_extension(user_ext) await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
@@ -230,13 +217,13 @@ async def api_enable_extension(
@extension_router.put("/{ext_id}/disable") @extension_router.put("/{ext_id}/disable")
async def api_disable_extension( async def api_disable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist." HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active: if not user_ext or not user_ext.active:
return SimpleStatus( return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled." success=True, message=f"Extension '{ext_id}' already disabled."
@@ -387,9 +374,7 @@ async def get_pay_to_install_invoice(
@extension_router.put("/{ext_id}/invoice/enable") @extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice( async def get_pay_to_enable_invoice(
ext_id: str, ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
data: PayToEnableInfo,
account_id: AccountId = Depends(check_account_id_exists),
): ):
if not data.amount or data.amount <= 0: if not data.amount or data.amount <= 0:
raise HTTPException( raise HTTPException(
@@ -435,9 +420,9 @@ async def get_pay_to_enable_invoice(
memo=f"Enable '{ext.name}' extension.", memo=f"Enable '{ext.name}' extension.",
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo() user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash user_ext_info.payment_hash_to_enable = payment.payment_hash
@@ -448,7 +433,7 @@ async def get_pay_to_enable_invoice(
@extension_router.get( @extension_router.get(
"/release/{org}/{repo}/{tag_name}", "/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
@@ -469,18 +454,15 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
@extension_router.get("") @extension_router.get("")
async def api_get_user_extensions( async def api_get_user_extensions(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> list[Extension]: ) -> list[Extension]:
async with db.connect() as conn:
user_extensions_ids = [ user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn) return [
] ext
valid_extensions = [ for ext in await get_valid_extensions(False)
ext if ext.code in user_extensions_ids
for ext in await get_valid_extensions(False, conn=conn) ]
if ext.code in user_extensions_ids
]
return valid_extensions
@extension_router.delete( @extension_router.delete(
@@ -510,135 +492,3 @@ async def delete_extension_db(ext_id: str):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'", detail=f"Cannot delete data for extension '{ext_id}'",
) from exc ) from exc
# TODO: create a response model for this
@extension_router.get("/all")
async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
async with db.connect() as conn:
installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn
)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn)
]
db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
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
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
return extension_data
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+25 -13
View File
@@ -1,12 +1,14 @@
import os import os
import shutil import shutil
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
Extension, Extension,
@@ -15,7 +17,6 @@ from lnbits.core.models.extensions import (
UserExtension, UserExtension,
) )
from lnbits.core.models.extensions_builder import ExtensionData from lnbits.core.models.extensions_builder import ExtensionData
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
install_extension, install_extension,
@@ -26,10 +27,10 @@ from lnbits.core.services.extensions_builder import (
zip_directory, zip_directory,
) )
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists,
check_admin, check_admin,
check_extension_builder, check_user_exists,
) )
from lnbits.settings import settings
from ..crud import ( from ..crud import (
create_user_extension, create_user_extension,
@@ -46,12 +47,19 @@ extension_builder_router = APIRouter(
@extension_builder_router.post( @extension_builder_router.post(
"/zip", "/zip",
summary="Build and download extension zip.", summary="Build and download extension zip.",
dependencies=[Depends(check_extension_builder)],
description=""" description="""
This endpoint generates a zip file for the extension based on the provided data. This endpoint generates a zip file for the extension based on the provided data.
""", """,
) )
async def api_build_extension(data: ExtensionData) -> FileResponse: 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 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) release, build_dir = await build_extension_from_data(data, stub_ext_id)
@@ -84,9 +92,9 @@ async def api_build_extension(data: ExtensionData) -> FileResponse:
) )
async def api_deploy_extension( async def api_deploy_extension(
data: ExtensionData, data: ExtensionData,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
working_dir_name = "deploy_" + sha256(account.id.encode("utf-8")).hexdigest() working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
stub_ext_id = "extension_builder_stub" stub_ext_id = "extension_builder_stub"
release, build_dir = await build_extension_from_data( release, build_dir = await build_extension_from_data(
data, stub_ext_id, working_dir_name data, stub_ext_id, working_dir_name
@@ -110,9 +118,9 @@ async def api_deploy_extension(
await activate_extension(Extension.from_installable_ext(ext_info)) await activate_extension(Extension.from_installable_ext(ext_info))
user_ext = await get_user_extension(account.id, data.id) user_ext = await get_user_extension(user.id, data.id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account.id, extension=data.id, active=True) user_ext = UserExtension(user=user.id, extension=data.id, active=True)
await create_user_extension(user_ext) await create_user_extension(user_ext)
elif not user_ext.active: elif not user_ext.active:
user_ext.active = True user_ext.active = True
@@ -124,14 +132,18 @@ async def api_deploy_extension(
@extension_builder_router.post( @extension_builder_router.post(
"/preview", "/preview",
summary="Build and preview the extension ui.", summary="Build and preview the extension ui.",
dependencies=[Depends(check_extension_builder)],
) )
async def api_preview_extension( async def api_preview_extension(
data: ExtensionData, data: ExtensionData,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> SimpleStatus: ) -> 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" stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(account_id.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name) await build_extension_from_data(data, stub_ext_id, working_dir_name)
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.") return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
+15 -35
View File
@@ -1,7 +1,6 @@
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.models.wallets import WalletTypeInfo
@@ -31,11 +30,6 @@ async def create_subscription(
data: CreateFiatSubscription, data: CreateFiatSubscription,
key_type: WalletTypeInfo = Depends(require_admin_key), key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse: ) -> FiatSubscriptionResponse:
logger.debug(
f"Creating subscription with provider '{provider}. '"
f"Subscription ID '{data.subscription_id}'."
)
fiat_provider = await get_fiat_provider(provider) fiat_provider = await get_fiat_provider(provider)
if not fiat_provider: if not fiat_provider:
raise HTTPException(404, "Fiat provider not found") raise HTTPException(404, "Fiat provider not found")
@@ -53,12 +47,6 @@ async def create_subscription(
subscription_response = await fiat_provider.create_subscription( subscription_response = await fiat_provider.create_subscription(
data.subscription_id, data.quantity, data.payment_options data.subscription_id, data.quantity, data.payment_options
) )
if subscription_response.error_message:
logger.warning(
f"Failed to create subscription with provider '{provider}': "
f"{subscription_response.error_message}"
)
return subscription_response return subscription_response
@@ -87,28 +75,20 @@ async def cancel_subscription(
) )
async def connection_token(provider: str): async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider) fiat_provider = await get_fiat_provider(provider)
if not fiat_provider: if provider == "stripe":
raise HTTPException(status_code=404, detail="Fiat provider not found") if not isinstance(fiat_provider, StripeWallet):
if provider != "stripe":
raise HTTPException(
status_code=400,
detail=f"Connection tokens are not supported for provider '{provider}'.",
)
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException( raise HTTPException(
status_code=502, detail="Stripe returned no connection token" status_code=500, detail="Stripe wallet/provider not configured"
) )
return {"secret": secret} try:
except Exception as e: tok = await fiat_provider.create_terminal_connection_token()
raise HTTPException( secret = tok.get("secret")
status_code=500, detail="Failed to create connection token" if not secret:
) from e 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

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