Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da683e549 |
@@ -23,10 +23,3 @@ mypy.ini
|
||||
package-lock.json
|
||||
package.json
|
||||
pytest.ini
|
||||
|
||||
.mypy_cache
|
||||
.github
|
||||
.pytest_cache
|
||||
.vscode
|
||||
bin
|
||||
dist
|
||||
|
||||
+7
-71
@@ -6,14 +6,6 @@
|
||||
# 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.
|
||||
|
||||
# === First Install Token ===
|
||||
# If set the user is required to enter this token on the /first_install page
|
||||
# FIRST_INSTALL_TOKEN="myaccesstoken"
|
||||
|
||||
# === Security ===
|
||||
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
AUTH_HTTPS_ONLY=true
|
||||
|
||||
# === Logging and Development ===
|
||||
|
||||
DEBUG=False
|
||||
@@ -26,12 +18,9 @@ ENABLE_LOG_TO_FILE=true
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
# Hard limit for total created users. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_USERS=0
|
||||
# Hard limit for total installed extensions. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_EXTENSIONS=0
|
||||
|
||||
# === Admin Settings ===
|
||||
|
||||
@@ -67,7 +56,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, 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
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
@@ -102,7 +91,7 @@ AUTH_SECRET_KEY=""
|
||||
######################################
|
||||
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
@@ -198,15 +187,7 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||
|
||||
# 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
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
@@ -279,50 +260,6 @@ KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# OIDC OAuth Config
|
||||
# Generic OIDC provider configuration
|
||||
# Make sure that the redirect URI in your OIDC provider is set to: https://{domain}/api/v1/auth/oidc/token
|
||||
# Required scopes: openid, email, profile
|
||||
# The discovery URL must be accessible from your LNbits server
|
||||
# Always use HTTPS in production environments
|
||||
# The CUSTOM_ORG and CUSTOM_ICON settings allow you to customize the login button
|
||||
# For example: "Login via Zitadel" with the Zitadel logo
|
||||
OIDC_DISCOVERY_URL=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_CLIENT_CUSTOM_ORG=""
|
||||
OIDC_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# Example OIDC configurations for various providers:
|
||||
#
|
||||
# ZITADEL:
|
||||
# OIDC_DISCOVERY_URL=https://login.yourdomain.de/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-zitadel-client-id@project-id
|
||||
# OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Zitadel
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/zitadel.png
|
||||
#
|
||||
# AUTHENTIK:
|
||||
# OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/lnbits/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authentik-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authentik
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authentik.png
|
||||
#
|
||||
# AUTHELIA:
|
||||
# OIDC_DISCOVERY_URL=https://auth.yourdomain.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authelia-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authelia-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authelia
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authelia.png
|
||||
#
|
||||
# OKTA:
|
||||
# OIDC_DISCOVERY_URL=https://your-domain.okta.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-okta-client-id
|
||||
# OIDC_CLIENT_SECRET=your-okta-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Okta
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/okta.png
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
@@ -378,12 +315,10 @@ LNBITS_SERVICE_FEE=0.0
|
||||
# disable fees for internal transactions
|
||||
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
|
||||
|
||||
# The minimum fee reserved per payment (millisats)
|
||||
# value in millisats
|
||||
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
|
||||
# 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
|
||||
# throw an error if the wallet attempts to create a new invoice
|
||||
@@ -398,3 +333,4 @@ LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
|
||||
######################################
|
||||
###### Logging and Development #######
|
||||
######################################
|
||||
|
||||
|
||||
@@ -69,10 +69,7 @@ jobs:
|
||||
--onefile \
|
||||
--name lnbits \
|
||||
--hidden-import=embit \
|
||||
--hidden-import=bitstring.bitstore_bitarray \
|
||||
--collect-all embit \
|
||||
--collect-all bitstring \
|
||||
--collect-all bitarray \
|
||||
--collect-all lnbits \
|
||||
--collect-all sqlalchemy \
|
||||
--collect-all breez_sdk \
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
name: bundle
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- run: make bundle
|
||||
- name: Commit and push bundle changes
|
||||
run: |
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
+12
-18
@@ -1,9 +1,14 @@
|
||||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
@@ -11,7 +16,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -25,7 +30,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -39,7 +44,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -53,7 +58,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -69,15 +74,8 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
- LndWallet
|
||||
- CoreLightningWallet
|
||||
- CoreLightningRestWallet
|
||||
- LNbitsWallet
|
||||
- EclairWallet
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
custom-pytest: "uv run pytest tests/regtest"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -89,11 +87,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
python-version: ["3.10"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
bundle:
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||
uses: ./.github/workflows/bundle.yml
|
||||
|
||||
@@ -55,21 +55,10 @@ jobs:
|
||||
- name: Build and push boltz
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: docker/boltzclient
|
||||
context: .
|
||||
file: Dockerfile.boltz
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ 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 }}
|
||||
|
||||
- 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 }}
|
||||
|
||||
@@ -22,7 +22,6 @@ jobs:
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
AUTH_HTTPS_ONLY: false
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
|
||||
@@ -6,30 +6,53 @@ jobs:
|
||||
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
npm: true
|
||||
|
||||
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
bundle:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
|
||||
poetry:
|
||||
uses: ./.github/workflows/poetry.yml
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.12"
|
||||
default: "3.10"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["24.x"]
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.12"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
@@ -38,9 +38,10 @@ jobs:
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
cd docker/regtest
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
|
||||
- name: Run pytest
|
||||
@@ -49,21 +50,19 @@ jobs:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: ./docker/regtest/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_GRPC_ENDPOINT: localhost
|
||||
LND_GRPC_PORT: 10009
|
||||
LND_GRPC_CERT: ./docker/regtest/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/regtest/data/clightning-1/regtest/lightning-rpc
|
||||
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
CORELIGHTNING_REST_URL: https://localhost:3001
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/regtest/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/regtest/data/clightning-2-rest/certificate.pem
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
|
||||
LNBITS_ENDPOINT: http://localhost:5001
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
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_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
ECLAIR_PASS: lnbits
|
||||
|
||||
@@ -10,30 +10,7 @@ permissions:
|
||||
|
||||
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:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -41,37 +18,15 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
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:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- 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 }}
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -22,18 +22,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --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")
|
||||
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -43,7 +35,6 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -52,33 +43,18 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
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:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- 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
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.12"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
+1
-1
@@ -6,7 +6,6 @@ __pycache__
|
||||
*$py.class
|
||||
.mypy_cache
|
||||
.vscode
|
||||
.codex
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
@@ -43,6 +42,7 @@ lnbits/static/bundle.min.js.old
|
||||
lnbits/static/bundle.min.css.old
|
||||
lnbits/static/bundle-components.min.js.old
|
||||
lnbits/upgrades
|
||||
docker
|
||||
|
||||
# Nix
|
||||
*result*
|
||||
|
||||
@@ -14,16 +14,16 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 26.3.1
|
||||
rev: 25.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
rev: v0.12.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.7.4
|
||||
rev: v3.6.2
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
|
||||
+1
-1
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
ARG LNBITS_TAG=latest
|
||||
|
||||
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/
|
||||
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)
|
||||
RUN uv sync --all-extras
|
||||
|
||||
# LNbits
|
||||
# LNbits + boltzd configuration
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES="10"
|
||||
|
||||
# Boltzd
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
||||
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
|
||||
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
|
||||
@@ -28,7 +25,8 @@ ENV BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
# Entrypoint to start boltzd and LNbits
|
||||
COPY dockerboltz.sh /dockerboltz.sh
|
||||
RUN chmod +x /dockerboltz.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/dockerboltz.sh"]
|
||||
@@ -66,7 +66,6 @@ test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
rm -rf ./tests/data \
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
|
||||
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](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 alt="lnbits_head" src="docs/assets/header.jpg" />
|
||||
[](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](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" />
|
||||
|
||||
# LNbits — The most powerful Bitcoin & Lightning toolkit
|
||||
|
||||
@@ -46,7 +45,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
|
||||
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
|
||||
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
|
||||
|
||||
## LNbits extension universe
|
||||
|
||||
@@ -54,35 +53,37 @@ Extend YOUR LNbits to meet YOUR needs.
|
||||
|
||||
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
|
||||
|
||||
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
|
||||
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
|
||||
|
||||
## LNbits API
|
||||
|
||||
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
|
||||
|
||||
<img alt="lnbits_api" src="docs/assets/api.jpg" />
|
||||
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
|
||||
|
||||
## LNbits node manager
|
||||
|
||||
LNbits comes packaged with a light node management UI, to make running your node that much easier.
|
||||
|
||||
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
|
||||
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
|
||||
|
||||
## LNbits merchant tools
|
||||
## LNbits across all your devices
|
||||
|
||||
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
|
||||
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
|
||||
|
||||
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
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)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
@@ -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
|
||||
@@ -1,99 +0,0 @@
|
||||

|
||||
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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='*'
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 317 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 305 KiB |
@@ -50,7 +50,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. |
|
||||
| **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 (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. |
|
||||
| **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. |
|
||||
@@ -58,9 +57,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. |
|
||||
| **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
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Generic OIDC Authentication Configuration
|
||||
|
||||
This document explains how to configure generic OIDC authentication for LNbits, which allows integration with various OIDC-compliant authentication providers such as Zitadel, Authentik, and others.
|
||||
|
||||
## Overview
|
||||
|
||||
The generic OIDC provider (`oidc`) complements the existing Keycloak provider and allows you to integrate any OIDC-compliant authentication service. You can customize the login button with your own organization name and icon.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following environment variables to your `.env` file or system environment:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```bash
|
||||
# Enable OIDC authentication
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
|
||||
# OIDC Discovery URL (well-known endpoint)
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
|
||||
# Client credentials from your OIDC provider
|
||||
LNBITS_OIDC_CLIENT_ID=your-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
### Optional Settings - Customize the Login Button
|
||||
|
||||
You can customize how the OIDC login button appears to your users:
|
||||
|
||||
```bash
|
||||
# Custom organization name (displayed on the login button)
|
||||
# Example: "Login via Zitadel" or "Login via Authentik"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
|
||||
# Custom icon URL (displayed on the login button)
|
||||
# Can be a full URL or a path to a local image
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON=https://zitadel.com/favicon.svg
|
||||
```
|
||||
|
||||
If not set, the button will display "Login via OIDC" with a generic lock icon.
|
||||
|
||||
## Zitadel Configuration Example
|
||||
|
||||
For Zitadel, configure as follows:
|
||||
|
||||
1. Create a new application in Zitadel
|
||||
2. Choose "Web" application type
|
||||
3. Configure the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Save the Client ID and Client Secret
|
||||
5. Use these environment variables:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-zitadel-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# Customize the button to show "Login via Zitadel" with Zitadel's logo
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON="https://zitadel.com/favicon.svg"
|
||||
```
|
||||
|
||||
**Result**: The login page will display a button with the text "Login via Zitadel" and the Zitadel logo.
|
||||
|
||||
## Authentik Configuration Example
|
||||
|
||||
For Authentik:
|
||||
|
||||
1. Create a new OAuth2/OpenID Provider
|
||||
2. Set the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
3. Configure scopes: `openid`, `email`, `profile`
|
||||
4. Get the Client ID and Client Secret
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/your-app/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-authentik-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Authentik"
|
||||
```
|
||||
|
||||
## Multiple Auth Methods
|
||||
|
||||
You can enable multiple authentication methods simultaneously:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=username-password,oidc-auth,keycloak-auth
|
||||
```
|
||||
|
||||
## Discovery Endpoint Requirements
|
||||
|
||||
Your OIDC provider must expose a standard discovery endpoint (`.well-known/openid-configuration`) that includes:
|
||||
|
||||
- `authorization_endpoint`
|
||||
- `token_endpoint`
|
||||
- `userinfo_endpoint`
|
||||
- `jwks_uri` (JSON Web Key Set)
|
||||
|
||||
The OIDC implementation will automatically fetch these endpoints from the discovery URL.
|
||||
|
||||
## User Mapping
|
||||
|
||||
The OIDC provider maps user information from the OIDC userinfo endpoint:
|
||||
|
||||
- `sub` → User ID
|
||||
- `email` → Email address
|
||||
- `given_name` → First name
|
||||
- `family_name` → Last name
|
||||
- `name` or `preferred_username` → Display name
|
||||
- `picture` → Profile picture URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication fails
|
||||
|
||||
1. Verify the discovery URL is accessible
|
||||
2. Check that Client ID and Client Secret are correct
|
||||
3. Ensure redirect URI in your OIDC provider matches: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Check LNbits logs for detailed error messages
|
||||
|
||||
### User info not populated
|
||||
|
||||
Some OIDC providers may use different claim names. If user information is not correctly populated, check your provider's userinfo endpoint response format and adjust the provider class if needed.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Keep client secrets secure and never commit them to version control
|
||||
- Use environment variables or secure configuration management
|
||||
- Regularly rotate client secrets
|
||||
- Review OIDC provider's security best practices
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The OIDC provider is implemented in `lnbits/core/models/sso/oidc.py` and extends the `fastapi_sso` library's `SSOBase` class. It uses the standard OpenID Connect flow with:
|
||||
|
||||
- Scopes: `openid`, `email`, `profile`
|
||||
- Response type: `code` (authorization code flow)
|
||||
- Discovery document for automatic endpoint resolution
|
||||
+2
-30
@@ -41,8 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
|
||||
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
|
||||
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
|
||||
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
|
||||
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
|
||||
| [Cliche Wallet](#cliche-wallet) | | |
|
||||
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
|
||||
| [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) |
|
||||
| [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))
|
||||
|
||||
[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)
|
||||
|
||||
**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_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)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .core.services import create_invoice, pay_invoice
|
||||
from .decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_super_user,
|
||||
check_user_exists,
|
||||
@@ -12,7 +11,6 @@ from .exceptions import InvoiceError, PaymentError
|
||||
__all__ = [
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_account_exists",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
|
||||
+1
-6
@@ -468,12 +468,7 @@ def register_async_tasks() -> None:
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(
|
||||
run_interval(
|
||||
settings.lnbits_funding_source_pending_interval_seconds,
|
||||
check_pending_payments,
|
||||
)
|
||||
)
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
@@ -12,7 +12,6 @@ from .extensions import (
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_installed_extensions_count,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
@@ -59,7 +58,6 @@ from .users import (
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_accounts_count,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
@@ -119,13 +117,11 @@ __all__ = [
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_accounts_count",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_installed_extensions_count",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
|
||||
@@ -90,13 +90,6 @@ async def get_installed_extensions(
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM installed_extensions"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
@@ -34,9 +33,9 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str,
|
||||
conn: Connection | None = None,
|
||||
incoming: bool | None = False,
|
||||
wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment | None:
|
||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||
values = {
|
||||
@@ -47,7 +46,7 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
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:
|
||||
return None
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
@@ -70,7 +69,7 @@ async def get_standalone_payment(
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
payment = await (conn or db).fetchone(
|
||||
@@ -125,6 +124,7 @@ async def get_payments_paginated( # noqa: C901
|
||||
Filters payments to be returned by:
|
||||
- complete | pending | failed | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict[str, Any] = {
|
||||
"time": since,
|
||||
}
|
||||
@@ -134,7 +134,7 @@ async def get_payments_paginated( # noqa: C901
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
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:
|
||||
return Page(data=[], total=0)
|
||||
|
||||
@@ -149,12 +149,14 @@ async def get_payments_paginated( # noqa: C901
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(f"""
|
||||
clause.append(
|
||||
f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
elif failed:
|
||||
@@ -303,16 +305,8 @@ async def update_payment_checking_id(
|
||||
checking_id: str, new_checking_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
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()),
|
||||
},
|
||||
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
||||
{"new_id": new_checking_id, "old_id": checking_id},
|
||||
)
|
||||
|
||||
|
||||
@@ -321,7 +315,6 @@ async def update_payment(
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
)
|
||||
@@ -333,7 +326,6 @@ async def get_payments_history(
|
||||
wallet_id: str | None = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Filters | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
@@ -344,12 +336,14 @@ async def get_payments_history(
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
where = [f"""
|
||||
where = [
|
||||
f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""]
|
||||
"""
|
||||
]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
@@ -367,13 +361,13 @@ async def get_payments_history(
|
||||
filters.values(values),
|
||||
)
|
||||
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:
|
||||
return []
|
||||
balance = wallet.balance_msat
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
else:
|
||||
balance = await get_total_balance(conn=conn)
|
||||
balance = await get_total_balance()
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
|
||||
@@ -105,8 +105,7 @@ async def get_settings_field(
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
value = json.loads(row["value"]) if row["value"] else None
|
||||
return SettingsField(id=row["id"], value=value, tag=row["tag"])
|
||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||
|
||||
|
||||
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
||||
|
||||
+40
-121
@@ -4,17 +4,10 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallets,
|
||||
)
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.helpers import sha256s
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
@@ -37,16 +30,9 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def get_accounts_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM accounts"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
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)
|
||||
await (conn or db).update("accounts", account)
|
||||
await db.update("accounts", account)
|
||||
return account
|
||||
|
||||
|
||||
@@ -55,7 +41,6 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
||||
"DELETE from accounts WHERE id = :user",
|
||||
{"user": user_id},
|
||||
)
|
||||
await clear_user_id_cache(user_id)
|
||||
|
||||
|
||||
async def get_accounts(
|
||||
@@ -64,22 +49,17 @@ async def get_accounts(
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
filters = filters or Filters()
|
||||
|
||||
wallet_filter = filters.get_filter_by_field("wallet_id")
|
||||
|
||||
if wallet_filter and wallet_filter.values:
|
||||
wallet_id_value = next(iter(wallet_filter.values.values()), None)
|
||||
wallet = (
|
||||
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn)
|
||||
if wallet_id_value
|
||||
else None
|
||||
)
|
||||
if not wallet:
|
||||
return Page(data=[], total=0)
|
||||
where_clauses.append("accounts.id = :account_id")
|
||||
values = {**values, "account_id": wallet.user}
|
||||
filters.remove_filter_by_field("wallet_id")
|
||||
# Make wallet filter explicit
|
||||
wallet_filter = (
|
||||
next((f for f in filters.filters if f.field == "wallet_id"), None)
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
if filters and wallet_filter and wallet_filter.values:
|
||||
where_clauses.append("wallets.id = :wallet_id")
|
||||
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
|
||||
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
@@ -89,7 +69,6 @@ async def get_accounts(
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.external_id,
|
||||
accounts.activated,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -114,18 +93,12 @@ async def get_accounts(
|
||||
)
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
|
||||
if len(user_id) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE id = :id AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"id": user_id, "activated": active_only},
|
||||
"SELECT * FROM accounts WHERE id = :id",
|
||||
{"id": user_id},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -151,98 +124,66 @@ async def delete_accounts_no_wallets(
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, active_only: bool = True, conn: Connection | None = None
|
||||
username: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(username) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(username) = :username
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"username": username.lower(), "activated": active_only},
|
||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_pubkey(
|
||||
pubkey: str, active_only: bool = True, conn: Connection | None = None
|
||||
pubkey: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(pubkey) = :pubkey
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"pubkey": pubkey.lower(), "activated": active_only},
|
||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||
{"pubkey": pubkey.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, active_only: bool = True, conn: Connection | None = None
|
||||
email: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(email) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(email) = :email
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"email": email.lower(), "activated": active_only},
|
||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||
{"email": email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str,
|
||||
active_only: bool = True,
|
||||
conn: Connection | None = None,
|
||||
username_or_email: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
(LOWER(email) = :value or LOWER(username) = :value)
|
||||
AND (activated = true OR activated = :activated)
|
||||
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||
""",
|
||||
{"value": username_or_email.lower(), "activated": active_only},
|
||||
{"value": username_or_email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_user(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
account = await get_account(user_id, active_only, conn=conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn=conn)
|
||||
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
||||
account = await get_account(user_id, conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn)
|
||||
|
||||
|
||||
async def get_user_from_account(
|
||||
account: Account, conn: Connection | None = 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=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)
|
||||
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||
wallets = await get_wallets(account.id, False, conn=conn)
|
||||
return User(
|
||||
id=account.id,
|
||||
activated=account.activated,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
@@ -256,43 +197,21 @@ async def get_user_from_account(
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
ui_customization=account.ui_customization or {},
|
||||
)
|
||||
|
||||
|
||||
async def update_user_access_control_list(
|
||||
user_acls: UserAcls, conn: Connection | None = None
|
||||
):
|
||||
async def update_user_access_control_list(user_acls: UserAcls):
|
||||
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(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
user_id: str, conn: Connection | None = None
|
||||
) -> UserAcls:
|
||||
user_acls = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id, access_control_list FROM accounts
|
||||
WHERE id = :user_id AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"user_id": user_id, "activated": active_only},
|
||||
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||
{"id": user_id},
|
||||
UserAcls,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,10 +3,9 @@ from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
|
||||
from lnbits.core.models.wallets import WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..models import Wallet
|
||||
|
||||
@@ -45,14 +44,13 @@ async def update_wallet(
|
||||
|
||||
|
||||
async def delete_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
deleted: bool = True,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
now = int(time())
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
@@ -65,7 +63,6 @@ async def delete_wallet(
|
||||
|
||||
|
||||
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM wallets WHERE id = :wallet",
|
||||
{"wallet": wallet_id},
|
||||
@@ -75,7 +72,6 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
|
||||
async def delete_wallet_by_id(
|
||||
wallet_id: str, conn: Connection | None = None
|
||||
) -> int | None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
@@ -226,14 +222,11 @@ async def get_wallet_for_key(
|
||||
) -> Wallet | None:
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT wallets.*, COALESCE((
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)
|
||||
AS balance_msat FROM wallets
|
||||
INNER JOIN accounts ON wallets.user = accounts.id
|
||||
WHERE (adminkey = :key OR inkey = :key)
|
||||
AND deleted = false
|
||||
AND accounts.activated = true
|
||||
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
|
||||
""",
|
||||
{"key": key},
|
||||
Wallet,
|
||||
@@ -247,27 +240,6 @@ async def get_wallet_for_key(
|
||||
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:
|
||||
@@ -298,16 +270,3 @@ async def get_total_balance(conn: Connection | None = None):
|
||||
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
||||
row = result.mappings().first()
|
||||
return row.get("balance", 0) or 0
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
+112
-128
@@ -10,26 +10,31 @@ from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
"user" TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
@@ -37,8 +42,10 @@ async def m001_initial(db: Connection):
|
||||
|
||||
UNIQUE ("user", extension)
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -46,8 +53,10 @@ async def m001_initial(db: Connection):
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
""")
|
||||
await db.execute(f"""
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
@@ -58,9 +67,11 @@ async def m001_initial(db: Connection):
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -74,7 +85,8 @@ async def m001_initial(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
@@ -137,7 +149,8 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -151,7 +164,8 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
@@ -160,7 +174,8 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS balance_check (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
@@ -168,16 +183,19 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS balance_notify (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
@@ -244,16 +262,19 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -261,11 +282,13 @@ async def m009_create_tinyurl_table(db: Connection):
|
||||
wallet TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
@@ -276,7 +299,8 @@ async def m010_create_installed_extensions_table(db: Connection):
|
||||
active BOOLEAN DEFAULT false,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
@@ -285,19 +309,23 @@ async def m011_optimize_balances_view(db: Connection):
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
@@ -317,13 +345,15 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute("""
|
||||
result = await db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
""")
|
||||
"""
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
for row in rows:
|
||||
@@ -356,7 +386,8 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
@@ -365,7 +396,8 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
@@ -452,7 +484,8 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -462,7 +495,8 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY wallet
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
@@ -471,7 +505,8 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
Important for querying whole lnbits balances.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -481,7 +516,8 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY apipayments.wallet
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db: Connection):
|
||||
@@ -500,7 +536,8 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -512,7 +549,8 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
@@ -543,7 +581,8 @@ async def m024_drop_pending(db: Connection):
|
||||
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet_id,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -555,7 +594,8 @@ async def m025_refresh_view(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet_id
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m026_update_payment_table(db: Connection):
|
||||
@@ -618,7 +658,8 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
|
||||
async def m028_update_settings(db: Connection):
|
||||
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
@@ -626,7 +667,8 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
@@ -649,7 +691,8 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
component TEXT,
|
||||
ip_address TEXT,
|
||||
@@ -663,13 +706,16 @@ async def m029_create_audit_table(db: Connection):
|
||||
delete_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m030_add_user_api_tokens_column(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
@@ -692,25 +738,32 @@ async def m033_update_payment_table(db: Connection):
|
||||
|
||||
|
||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute("""
|
||||
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("""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -723,82 +776,13 @@ async def m037_create_assets_table(db: Connection):
|
||||
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("""
|
||||
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")
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ from .users import (
|
||||
UserAcls,
|
||||
UserExtra,
|
||||
)
|
||||
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo
|
||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
@@ -57,6 +57,7 @@ __all__ = [
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"CancelInvoice",
|
||||
"ConversionData",
|
||||
@@ -98,7 +99,6 @@ __all__ = [
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
"Wallet",
|
||||
"WalletInfo",
|
||||
"WalletTypeInfo",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -6,13 +6,12 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -21,7 +20,6 @@ from lnbits.helpers import (
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
@@ -43,8 +41,6 @@ class ExplicitRelease(BaseModel):
|
||||
details_link: str | None
|
||||
paid_features: str | None
|
||||
pay_link: str | None
|
||||
admin_only: bool = False
|
||||
super_user_only: bool = False
|
||||
|
||||
def is_version_compatible(self):
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -54,8 +50,6 @@ class GitHubRelease(BaseModel):
|
||||
id: str
|
||||
organisation: str
|
||||
repository: str
|
||||
admin_only: bool = False
|
||||
super_user_only: bool = False
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
@@ -87,8 +81,6 @@ class ExtensionConfig(BaseModel):
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
admin_only: bool = False
|
||||
super_user_only: bool = False
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -201,8 +193,6 @@ class ExtensionRelease(BaseModel):
|
||||
cost_sats: int | None = None
|
||||
paid_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
admin_only: bool = False
|
||||
super_user_only: bool = False
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
@@ -271,40 +261,16 @@ class ExtensionRelease(BaseModel):
|
||||
paid_features=e.paid_features,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
admin_only=e.admin_only,
|
||||
super_user_only=e.super_user_only,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
||||
try:
|
||||
github_releases = await cls.fetch_github_releases(org, repo)
|
||||
extension_releases = [
|
||||
return [
|
||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||
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.admin_only = config.admin_only
|
||||
release.super_user_only = config.super_user_only
|
||||
|
||||
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
|
||||
|
||||
return extension_releases
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return []
|
||||
@@ -348,8 +314,6 @@ class ExtensionMeta(BaseModel):
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
admin_only: bool = False
|
||||
super_user_only: bool = False
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
@@ -413,16 +377,6 @@ class InstallableExtension(BaseModel):
|
||||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
@property
|
||||
def is_admin_only(self) -> bool:
|
||||
return settings.is_admin_extension(self.id) or bool(
|
||||
self.meta and self.meta.admin_only
|
||||
)
|
||||
|
||||
@property
|
||||
def is_super_user_only(self) -> bool:
|
||||
return bool(self.meta and self.meta.super_user_only)
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
@@ -478,16 +432,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
if self.meta:
|
||||
self.meta.admin_only = config_json.get("admin_only", False)
|
||||
self.meta.super_user_only = config_json.get("super_user_only", False)
|
||||
if self.meta.installed_release:
|
||||
self.meta.installed_release.admin_only = config_json.get(
|
||||
"admin_only", False
|
||||
)
|
||||
self.meta.installed_release.super_user_only = config_json.get(
|
||||
"super_user_only", False
|
||||
)
|
||||
|
||||
if (
|
||||
self.meta
|
||||
@@ -530,10 +474,6 @@ class InstallableExtension(BaseModel):
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta:
|
||||
self.meta = ExtensionMeta()
|
||||
self.meta.admin_only = release.admin_only
|
||||
self.meta.super_user_only = release.super_user_only
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
meta = self.meta or ExtensionMeta()
|
||||
meta.latest_release = release
|
||||
@@ -596,13 +536,6 @@ class InstallableExtension(BaseModel):
|
||||
github_release.organisation, github_release.repository
|
||||
)
|
||||
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
||||
admin_only = github_release.admin_only or config.admin_only
|
||||
super_user_only = github_release.super_user_only or config.super_user_only
|
||||
latest_extension_release = ExtensionRelease.from_github_release(
|
||||
source_repo, latest_release
|
||||
)
|
||||
latest_extension_release.admin_only = admin_only
|
||||
latest_extension_release.super_user_only = super_user_only
|
||||
return InstallableExtension(
|
||||
id=github_release.id,
|
||||
name=config.name,
|
||||
@@ -614,9 +547,9 @@ class InstallableExtension(BaseModel):
|
||||
config.tile,
|
||||
),
|
||||
meta=ExtensionMeta(
|
||||
admin_only=admin_only,
|
||||
super_user_only=super_user_only,
|
||||
latest_release=latest_extension_release,
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
source_repo, latest_release
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -625,12 +558,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
|
||||
meta = ExtensionMeta(
|
||||
archive=e.archive,
|
||||
dependencies=e.dependencies,
|
||||
admin_only=e.admin_only,
|
||||
super_user_only=e.super_user_only,
|
||||
)
|
||||
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
|
||||
return InstallableExtension(
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
@@ -660,8 +588,6 @@ class InstallableExtension(BaseModel):
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
meta=ExtensionMeta(
|
||||
admin_only=config_json.get("admin_only", False),
|
||||
super_user_only=config_json.get("super_user_only", False),
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
version=version,
|
||||
@@ -669,9 +595,7 @@ class InstallableExtension(BaseModel):
|
||||
source_repo=f"{conf_path}",
|
||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||
max_lnbits_version=config_json.get("max_lnbits_version"),
|
||||
admin_only=config_json.get("admin_only", False),
|
||||
super_user_only=config_json.get("super_user_only", False),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -682,37 +606,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@classmethod
|
||||
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,
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
@@ -773,13 +666,6 @@ class InstallableExtension(BaseModel):
|
||||
repo_releases = await ExtensionRelease.get_github_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
for repo_release in repo_releases:
|
||||
repo_release.admin_only = (
|
||||
repo_release.admin_only or r.admin_only
|
||||
)
|
||||
repo_release.super_user_only = (
|
||||
repo_release.super_user_only or r.super_user_only
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
|
||||
for e in manifest.extensions:
|
||||
@@ -858,32 +744,6 @@ class ExtensionDetailsRequest(BaseModel):
|
||||
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:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -57,29 +55,6 @@ class DataField(BaseModel):
|
||||
field_type += ' = "sat"'
|
||||
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:
|
||||
field_name = camel_to_snake(self.name)
|
||||
default_value = "null"
|
||||
|
||||
@@ -6,16 +6,23 @@ from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlWithdrawResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -123,23 +130,59 @@ class Payment(BaseModel):
|
||||
"fiat_"
|
||||
)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
logger.warning("payment.check_status() is deprecated.")
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
if self.is_internal:
|
||||
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(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
logger.warning("payment.check_fiat_status() is deprecated.")
|
||||
from lnbits.core.services.fiat_providers import check_fiat_status
|
||||
if not self.is_internal:
|
||||
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):
|
||||
@@ -153,15 +196,7 @@ class PaymentFilters(FilterModel):
|
||||
"labels",
|
||||
]
|
||||
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"amount",
|
||||
"fee",
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
]
|
||||
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""SSO authentication providers for LNbits"""
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Generic OIDC SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||
|
||||
|
||||
class OidcSSO(SSOBase):
|
||||
"""Class providing login via Generic OIDC OAuth (e.g., Zitadel, Authentik, etc.)"""
|
||||
|
||||
provider = "oidc"
|
||||
scope = ["openid", "email", "profile"]
|
||||
discovery_url = ""
|
||||
|
||||
async def openid_from_response(
|
||||
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||
) -> OpenID:
|
||||
"""Return OpenID from user information provided by OIDC provider"""
|
||||
return OpenID(
|
||||
email=response.get("email", ""),
|
||||
provider=self.provider,
|
||||
id=response.get("sub"),
|
||||
first_name=response.get("given_name"),
|
||||
last_name=response.get("family_name"),
|
||||
display_name=response.get("name") or response.get("preferred_username"),
|
||||
picture=response.get("picture"),
|
||||
)
|
||||
|
||||
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||
"""Get document containing handy urls"""
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await session.get(self.discovery_url)
|
||||
content = response.json()
|
||||
|
||||
return content
|
||||
@@ -1,6 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
@@ -8,4 +6,4 @@ class TinyURL(BaseModel):
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
time: float
|
||||
|
||||
@@ -172,23 +172,14 @@ class UserAcls(BaseModel):
|
||||
return None
|
||||
|
||||
|
||||
class AccountId(BaseModel):
|
||||
class Account(BaseModel):
|
||||
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
|
||||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
pubkey: str | None = None
|
||||
email: str | None = None
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -203,10 +194,6 @@ class Account(AccountId):
|
||||
self.is_admin = settings.is_admin_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:
|
||||
"""sets and returns the hashed password"""
|
||||
salt = gensalt()
|
||||
@@ -242,7 +229,6 @@ class Account(AccountId):
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
activated: bool = True
|
||||
transaction_count: int | None = 0
|
||||
wallet_count: int | None = 0
|
||||
balance_msat: int | None = 0
|
||||
@@ -251,7 +237,7 @@ class AccountOverview(Account):
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = [
|
||||
"id",
|
||||
"user",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
@@ -259,18 +245,17 @@ class AccountFilters(FilterModel):
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"id",
|
||||
"balance_msat",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
]
|
||||
|
||||
id: str | None = None
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
user: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
@@ -278,7 +263,6 @@ class AccountFilters(FilterModel):
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
activated: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
email: str | None = None
|
||||
@@ -292,7 +276,6 @@ class User(BaseModel):
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> list[str]:
|
||||
@@ -318,7 +301,6 @@ class RegisterUser(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: 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):
|
||||
@@ -362,7 +344,6 @@ class UpdateSuperuserPassword(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: 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):
|
||||
|
||||
@@ -11,7 +11,7 @@ from lnbits.db import FilterModel
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class WalletInfo(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
adminkey: str
|
||||
@@ -110,16 +110,13 @@ class WalletExtra(BaseModel):
|
||||
]
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
class Wallet(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
name: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
# Must be set only for shared wallets
|
||||
shared_wallet_id: str | None = None
|
||||
deleted: bool = False
|
||||
@@ -233,12 +230,6 @@ class WalletTypeInfo:
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseWalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: BaseWallet
|
||||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
@@ -8,7 +7,6 @@ from .notifications import enqueue_admin_notification, send_payment_notification
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
cancel_hold_invoice,
|
||||
check_payment_status,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
@@ -42,8 +40,6 @@ __all__ = [
|
||||
"calculate_fiat_amounts",
|
||||
"cancel_hold_invoice",
|
||||
"check_admin_settings",
|
||||
"check_fiat_status",
|
||||
"check_payment_status",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"check_webpush_settings",
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -17,7 +16,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
||||
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):
|
||||
if user_id not in settings.lnbits_assets_no_limit_users:
|
||||
user_assets_count = await get_user_assets_count(user_id)
|
||||
if user_assets_count >= settings.lnbits_max_assets_per_user:
|
||||
raise ValueError(
|
||||
@@ -30,7 +29,17 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
||||
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
|
||||
)
|
||||
|
||||
thumb_buffer = thumbnail_from_bytes(contents)
|
||||
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)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
@@ -39,32 +48,9 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
||||
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
|
||||
),
|
||||
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
|
||||
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
|
||||
|
||||
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
get_installed_extensions_count,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
@@ -17,7 +16,6 @@ from lnbits.core.crud.extensions import (
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
@@ -39,8 +37,6 @@ async def install_extension(
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
@@ -66,15 +62,6 @@ async def install_extension(
|
||||
return extension
|
||||
|
||||
|
||||
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
|
||||
if settings.lnbits_max_extensions == 0 or installed_ext:
|
||||
return
|
||||
|
||||
extensions_count = await get_installed_extensions_count()
|
||||
if extensions_count >= settings.lnbits_max_extensions:
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
@@ -162,9 +149,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
|
||||
|
||||
async def get_valid_extensions(
|
||||
include_deactivated: bool | None = True, conn: Connection | None = None
|
||||
include_deactivated: bool | None = True,
|
||||
) -> 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]
|
||||
|
||||
if include_deactivated:
|
||||
|
||||
@@ -20,7 +20,6 @@ from lnbits.helpers import (
|
||||
camel_to_words,
|
||||
download_url,
|
||||
lowercase_first_letter,
|
||||
snake_to_camel,
|
||||
)
|
||||
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)
|
||||
shutil.rmtree(ext_build_dir, True)
|
||||
|
||||
shutil.copytree(
|
||||
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",
|
||||
),
|
||||
)
|
||||
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
|
||||
return ext_build_dir
|
||||
|
||||
|
||||
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
||||
parsed_data = _parse_extension_data(data)
|
||||
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
|
||||
for py_file in py_files + test_files:
|
||||
for py_file in py_files:
|
||||
template_path = Path(ext_stub_dir, py_file).as_posix()
|
||||
rederer = _render_file(template_path, parsed_data)
|
||||
with open(template_path, "w", encoding="utf-8") as f:
|
||||
@@ -226,7 +209,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
||||
|
||||
_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(
|
||||
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",
|
||||
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(
|
||||
template_path,
|
||||
{
|
||||
@@ -278,12 +263,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
||||
"publicClientData",
|
||||
ext_stub_dir,
|
||||
)
|
||||
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
|
||||
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
|
||||
public_template_path = Path(
|
||||
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
|
||||
)
|
||||
template_path = public_template_path.as_posix()
|
||||
if not data.public_page.has_public_page:
|
||||
public_template_path.unlink(missing_ok=True)
|
||||
public_component_path.unlink(missing_ok=True)
|
||||
else:
|
||||
rederer = _render_file(
|
||||
template_path,
|
||||
@@ -323,10 +308,8 @@ def zip_directory(source_dir, zip_path):
|
||||
|
||||
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
||||
extension_dir_path = extension_dir.as_posix()
|
||||
# the order of fields is important, do not chage
|
||||
rename_values = {
|
||||
"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": data.id,
|
||||
"OwnerData": data.owner_data.name,
|
||||
@@ -345,7 +328,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
|
||||
directory=extension_dir_path,
|
||||
old_text=old_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(
|
||||
@@ -423,7 +406,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
|
||||
"owner_data": {
|
||||
"name": data.owner_data.name,
|
||||
"editable": data.owner_data.editable,
|
||||
"fields": [field.name for field in data.owner_data.fields],
|
||||
"js_fields": [
|
||||
field.field_to_js()
|
||||
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],
|
||||
"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": {
|
||||
"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],
|
||||
"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": {
|
||||
"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}")
|
||||
|
||||
|
||||
def _is_excluded_dir(path: str) -> bool:
|
||||
def _is_excluded_dir(path):
|
||||
for excluded_dir in excluded_dirs:
|
||||
if path.startswith(excluded_dir):
|
||||
return True
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
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.db import Connection
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@@ -35,40 +27,6 @@ async def handle_fiat_payment_confirmation(
|
||||
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(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
@@ -112,63 +70,6 @@ def check_stripe_signature(
|
||||
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:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import smtplib
|
||||
from asyncio.tasks import create_task
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
@@ -287,13 +286,6 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
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):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
|
||||
@@ -13,19 +13,18 @@ from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -48,8 +47,7 @@ from ..models import (
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .notifications import send_payment_notification_in_background
|
||||
from .notifications import send_payment_notification
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||
@@ -69,14 +67,12 @@ async def pay_invoice(
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
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.",
|
||||
@@ -99,12 +95,10 @@ async def pay_invoice(
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
payment = await _pay_invoice(
|
||||
wallet.source_wallet_id, create_payment_model, conn=new_conn
|
||||
)
|
||||
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
|
||||
|
||||
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -203,22 +197,20 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
async with db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
conn=conn,
|
||||
)
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
)
|
||||
|
||||
if data.lnurl_withdraw:
|
||||
try:
|
||||
@@ -363,15 +355,13 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
status = await check_payment_status(payment)
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
await update_payment(payment)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -509,8 +499,6 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
from lnbits.tasks import internal_invoice_queue_put
|
||||
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
@@ -622,29 +610,7 @@ async def check_transaction_status(
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
return await check_payment_status(payment)
|
||||
|
||||
|
||||
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
|
||||
return await payment.check_status()
|
||||
|
||||
|
||||
async def get_payments_daily_stats(
|
||||
@@ -732,7 +698,6 @@ async def _pay_internal_invoice(
|
||||
internal_payment = await check_internal(
|
||||
create_payment_model.payment_hash, conn=conn
|
||||
)
|
||||
|
||||
if not internal_payment:
|
||||
return None
|
||||
|
||||
@@ -741,7 +706,6 @@ async def _pay_internal_invoice(
|
||||
internal_invoice = await get_standalone_payment(
|
||||
internal_payment.checking_id, incoming=True, conn=conn
|
||||
)
|
||||
|
||||
if not internal_invoice:
|
||||
raise PaymentError("Internal payment not found.", status="failed")
|
||||
|
||||
@@ -763,7 +727,6 @@ async def _pay_internal_invoice(
|
||||
|
||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
data=create_payment_model,
|
||||
@@ -778,7 +741,7 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
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
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -821,8 +784,6 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
from lnbits.tasks import create_task
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
@@ -853,8 +814,7 @@ async def _pay_external_invoice(
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
@@ -897,7 +857,7 @@ async def _verify_external_payment(
|
||||
raise PaymentError("Payment already paid.", status="success")
|
||||
|
||||
# payment failed
|
||||
status = await check_payment_status(payment)
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
raise PaymentError(
|
||||
"Payment is failed node, retrying is not possible.", status="failed"
|
||||
@@ -1062,40 +1022,3 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
|
||||
await update_payment(payment)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def update_invoice_callback(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
|
||||
or internal_invoice_queue. Checks its status, updates and returns it.
|
||||
returns None if no payment was found or it not and incoming payment.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
return None
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
return payment
|
||||
|
||||
@@ -3,11 +3,7 @@ from uuid import uuid4
|
||||
|
||||
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.users import RegisterUser
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
@@ -23,7 +19,6 @@ from ..crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_accounts_count,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
@@ -53,60 +48,43 @@ async def create_user_account_no_ckeck(
|
||||
account: Account | None = None,
|
||||
wallet_name: str | None = None,
|
||||
default_exts: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
await check_users_limit(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:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(account.username):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if account.email and await get_account_by_email(account.email, conn=conn):
|
||||
raise ValueError("Email already exists.")
|
||||
if account.email and await get_account_by_email(account.email):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if account.pubkey and await get_account_by_pubkey(
|
||||
account.pubkey, conn=conn
|
||||
):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
if account.pubkey and await get_account_by_pubkey(account.pubkey):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
|
||||
account = await create_account(account, conn=conn)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
conn=conn,
|
||||
)
|
||||
account = await create_account(account)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
)
|
||||
|
||||
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, conn=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
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, conn=conn)
|
||||
user = await get_user_from_account(account)
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_users_limit(conn: Connection | None = None):
|
||||
if settings.lnbits_max_users == 0:
|
||||
return
|
||||
|
||||
users_count = await get_accounts_count(conn=conn)
|
||||
if users_count >= settings.lnbits_max_users:
|
||||
raise ValueError("Max amount of users have been created")
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
@@ -185,12 +163,6 @@ async def check_admin_settings():
|
||||
if account and account.extra and account.extra.provider == "env":
|
||||
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(
|
||||
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
@@ -212,25 +184,3 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
||||
|
||||
editable_settings = EditableSettings.from_dict(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.")
|
||||
|
||||
@@ -57,9 +57,7 @@ async def run_by_the_minute_tasks() -> None:
|
||||
if minute_counter % 60 == 0:
|
||||
try:
|
||||
# initialize the list of all extensions
|
||||
await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=True
|
||||
)
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
@@ -8,8 +8,8 @@ from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, Depends, File
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services import (
|
||||
enqueue_admin_notification,
|
||||
get_balance_delta,
|
||||
@@ -67,9 +67,9 @@ async def api_test_email():
|
||||
|
||||
@admin_router.get("/api/v1/settings")
|
||||
async def api_get_settings(
|
||||
account: Account = Depends(check_admin),
|
||||
user: User = Depends(check_admin),
|
||||
) -> AdminSettings | None:
|
||||
admin_settings = await get_admin_settings(account.is_super_user)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
return admin_settings
|
||||
|
||||
|
||||
@@ -77,14 +77,12 @@ async def api_get_settings(
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(
|
||||
data: UpdateSettings, account: Account = Depends(check_admin)
|
||||
):
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
enqueue_admin_notification(
|
||||
NotificationType.settings_update, {"username": account.username}
|
||||
NotificationType.settings_update, {"username": user.username}
|
||||
)
|
||||
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:
|
||||
raise ValueError("Updated admin settings not found.")
|
||||
update_cached_settings(admin_settings.dict())
|
||||
@@ -96,11 +94,9 @@ async def api_update_settings(
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings_partial(
|
||||
data: dict, account: Account = Depends(check_admin)
|
||||
):
|
||||
async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
|
||||
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(
|
||||
@@ -114,9 +110,9 @@ async def api_reset_settings(field_name: str):
|
||||
|
||||
|
||||
@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(
|
||||
NotificationType.settings_update, {"username": account.username}
|
||||
NotificationType.settings_update, {"username": user.username}
|
||||
)
|
||||
await reset_core_settings()
|
||||
server_restart.set()
|
||||
|
||||
@@ -8,17 +8,13 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateWallet,
|
||||
User,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
@@ -43,9 +39,7 @@ async def health() -> dict:
|
||||
|
||||
|
||||
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
||||
async def health_check(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||
stat: dict[str, Any] = {
|
||||
"server_time": int(time()),
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
@@ -53,7 +47,7 @@ async def health_check(
|
||||
}
|
||||
|
||||
stat["version"] = settings.version
|
||||
if not account_id.is_admin_id:
|
||||
if not user.admin:
|
||||
return stat
|
||||
|
||||
funding_source = get_funding_source()
|
||||
@@ -70,6 +64,7 @@ async def health_check(
|
||||
"/api/v1/wallets",
|
||||
name="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]:
|
||||
return user.wallets
|
||||
@@ -83,7 +78,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/rate/history",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
)
|
||||
async def api_exchange_rate_history() -> list[dict]:
|
||||
return settings.lnbits_exchange_rate_history
|
||||
|
||||
@@ -15,11 +15,11 @@ from lnbits.core.crud.assets import (
|
||||
)
|
||||
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.models.users import User
|
||||
from lnbits.core.services.assets import create_user_asset
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
parse_filters,
|
||||
)
|
||||
@@ -35,10 +35,10 @@ upload_file_param = File(...)
|
||||
summary="Get paginated list user assets",
|
||||
)
|
||||
async def api_get_user_assets(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters = Depends(parse_filters(AssetFilters)),
|
||||
) -> Page[AssetInfo]:
|
||||
return await get_user_assets(account_id.id, filters=filters)
|
||||
return await get_user_assets(user.id, filters=filters)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
@@ -48,20 +48,20 @@ async def api_get_user_assets(
|
||||
)
|
||||
async def api_get_asset(
|
||||
asset_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> AssetInfo:
|
||||
asset_info = await get_user_asset_info(account_id.id, asset_id)
|
||||
asset_info = await get_user_asset_info(user.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",
|
||||
"/{asset_id}/binary",
|
||||
name="Get user asset binary",
|
||||
summary="Get user asset binary data by ID",
|
||||
)
|
||||
async def api_get_asset_data(
|
||||
async def api_get_asset_binary(
|
||||
asset_id: str,
|
||||
user_id: str | None = Depends(optional_user_id),
|
||||
) -> Response:
|
||||
@@ -120,12 +120,12 @@ async def api_get_asset_thumbnail(
|
||||
async def api_update_asset(
|
||||
asset_id: str,
|
||||
data: AssetUpdate,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> AssetInfo:
|
||||
if account_id.is_admin_id:
|
||||
if user.admin:
|
||||
asset_info = await get_asset_info(asset_id)
|
||||
else:
|
||||
asset_info = await get_user_asset_info(account_id.id, asset_id)
|
||||
asset_info = await get_user_asset_info(user.id, asset_id)
|
||||
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
@@ -144,13 +144,13 @@ async def api_update_asset(
|
||||
summary="Upload user assets",
|
||||
)
|
||||
async def api_upload_asset(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
file: UploadFile = upload_file_param,
|
||||
public_asset: bool = False,
|
||||
) -> AssetInfo:
|
||||
asset = await create_user_asset(account_id.id, file, public_asset)
|
||||
asset = await create_user_asset(user.id, file, public_asset)
|
||||
|
||||
asset_info = await get_user_asset_info(account_id.id, asset.id)
|
||||
asset_info = await get_user_asset_info(user.id, asset.id)
|
||||
if not asset_info:
|
||||
raise ValueError("Failed to retrieve asset info after upload.")
|
||||
|
||||
@@ -164,11 +164,11 @@ async def api_upload_asset(
|
||||
)
|
||||
async def api_delete_asset(
|
||||
asset_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> SimpleStatus:
|
||||
asset = await get_user_asset(account_id.id, asset_id)
|
||||
asset = await get_user_asset(user.id, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
await delete_user_asset(account_id.id, asset_id)
|
||||
await delete_user_asset(user.id, asset_id)
|
||||
return SimpleStatus(success=True, message="Asset deleted successfully.")
|
||||
|
||||
+53
-186
@@ -4,15 +4,13 @@ import json
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from time import time
|
||||
from typing import Annotated
|
||||
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_sso.sso.base import OpenID, SSOBase
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.crud.users import (
|
||||
get_user_access_control_lists,
|
||||
update_user_access_control_list,
|
||||
@@ -27,16 +25,8 @@ from lnbits.core.models.users import (
|
||||
UpdateAccessControlList,
|
||||
)
|
||||
from lnbits.core.services import create_user_account
|
||||
from lnbits.core.services.users import (
|
||||
check_register_activation_settings,
|
||||
update_user_account,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
access_token_payload,
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.core.services.users import update_user_account
|
||||
from lnbits.decorators import access_token_payload, check_user_exists
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
decrypt_internal_message,
|
||||
@@ -90,7 +80,6 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
account = await get_account_by_username_or_email(data.username)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
return _auth_success_response(account.username, account.id, account.email)
|
||||
|
||||
|
||||
@@ -99,7 +88,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||
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:
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
@@ -107,8 +96,6 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
extra=UserExtra(provider="nostr"),
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -129,87 +116,14 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
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,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_impersonated",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
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,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
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")
|
||||
async def api_get_user_acls(
|
||||
request: Request,
|
||||
account: Account = Depends(check_account_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> UserAcls:
|
||||
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
|
||||
for acl in acls.access_control_list:
|
||||
@@ -222,7 +136,7 @@ async def api_get_user_acls(
|
||||
acl.endpoints.append(EndpointAccess(path=path, name=name))
|
||||
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")
|
||||
@@ -230,13 +144,13 @@ async def api_get_user_acls(
|
||||
async def api_update_user_acl(
|
||||
request: Request,
|
||||
data: UpdateAccessControlList,
|
||||
account: Account = Depends(check_account_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> UserAcls:
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
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)
|
||||
if acl:
|
||||
user_acls.access_control_list.remove(acl)
|
||||
@@ -261,30 +175,33 @@ async def api_update_user_acl(
|
||||
|
||||
@auth_router.delete("/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):
|
||||
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)
|
||||
await update_user_access_control_list(user_acls)
|
||||
|
||||
|
||||
@auth_router.post("/acl/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:
|
||||
if not data.expiration_time_minutes > 0:
|
||||
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):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
if not account.username:
|
||||
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)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
@@ -301,16 +218,18 @@ async def api_create_user_api_token(
|
||||
|
||||
@auth_router.delete("/acl/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):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
if not account.username:
|
||||
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)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
@@ -380,14 +299,12 @@ async def register(data: RegisterUser) -> JSONResponse:
|
||||
if not is_valid_username(data.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.")
|
||||
|
||||
if data.email and not is_valid_email_address(data.email):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||
|
||||
await check_register_activation_settings(data)
|
||||
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
email=data.email,
|
||||
@@ -401,20 +318,24 @@ async def register(data: RegisterUser) -> JSONResponse:
|
||||
@auth_router.put("/pubkey")
|
||||
async def update_pubkey(
|
||||
data: UpdateUserPubkey,
|
||||
account: Account = Depends(check_account_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> User | None:
|
||||
if data.user_id != account.id:
|
||||
if data.user_id != user.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if (
|
||||
data.pubkey
|
||||
and data.pubkey != account.pubkey
|
||||
and data.pubkey != user.pubkey
|
||||
and await get_account_by_pubkey(data.pubkey)
|
||||
):
|
||||
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)
|
||||
await update_account(account)
|
||||
return await get_user_from_account(account)
|
||||
@@ -423,19 +344,23 @@ async def update_pubkey(
|
||||
@auth_router.put("/password")
|
||||
async def update_password(
|
||||
data: UpdateUserPassword,
|
||||
account: Account = Depends(check_account_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> User | None:
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if data.user_id != account.id:
|
||||
if data.user_id != user.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
if (
|
||||
data.username
|
||||
and account.username != 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 ValueError("Account not found.")
|
||||
|
||||
# old accounts do not have a password
|
||||
if account.password_hash:
|
||||
if not data.password_old:
|
||||
@@ -492,13 +417,17 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
return _auth_success_response(account.username, user_id, account.email)
|
||||
|
||||
|
||||
@auth_router.patch("")
|
||||
@auth_router.put("/update")
|
||||
async def update(
|
||||
data: UpdateUser, account: Account = Depends(check_account_exists)
|
||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||
) -> User | None:
|
||||
if data.user_id != account.id:
|
||||
if data.user_id != user.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
|
||||
|
||||
if data.username:
|
||||
account.username = data.username
|
||||
if data.extra:
|
||||
@@ -508,54 +437,19 @@ async def update(
|
||||
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")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.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)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||
|
||||
account.username = data.username
|
||||
account.extra = account.extra or UserExtra()
|
||||
account.extra.provider = "lnbits"
|
||||
account.hash_password(data.password)
|
||||
await update_account(account)
|
||||
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)
|
||||
|
||||
|
||||
@@ -565,7 +459,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||
|
||||
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 account:
|
||||
@@ -584,7 +478,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
||||
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
|
||||
)
|
||||
await create_user_account(account)
|
||||
return _auth_redirect_response(redirect_path, account.id, email)
|
||||
return _auth_redirect_response(redirect_path, email)
|
||||
|
||||
|
||||
def _auth_success_response(
|
||||
@@ -599,20 +493,9 @@ def _auth_success_response(
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie(
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -629,28 +512,15 @@ def _auth_api_token_response(
|
||||
)
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(
|
||||
usr=user_id, sub="", email=email, auth_time=int(time())
|
||||
)
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie(
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
@@ -670,10 +540,7 @@ def _new_sso(provider: str) -> SSOBase | None:
|
||||
|
||||
sso_provider_class = _find_auth_provider_class(provider)
|
||||
sso_provider = sso_provider_class(
|
||||
client_id,
|
||||
client_secret,
|
||||
None,
|
||||
allow_insecure_http=not settings.auth_https_only,
|
||||
client_id, client_secret, None, allow_insecure_http=True
|
||||
)
|
||||
if (
|
||||
discovery_url
|
||||
|
||||
@@ -9,9 +9,7 @@ from lnbits.core.crud.payments import (
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_fiat_status,
|
||||
check_stripe_signature,
|
||||
verify_paypal_webhook,
|
||||
)
|
||||
from lnbits.core.services.payments import create_fiat_invoice
|
||||
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(
|
||||
provider_name: str, request: Request
|
||||
) -> SimpleStatus:
|
||||
logger.info(f"Received callback from provider: '{provider_name}'.")
|
||||
|
||||
if provider_name.lower() == "stripe":
|
||||
payload = await request.body()
|
||||
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}'.",
|
||||
)
|
||||
|
||||
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(
|
||||
success=False,
|
||||
message=f"Unknown fiat provider '{provider_name}'.",
|
||||
@@ -61,8 +48,6 @@ async def handle_stripe_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
if event_type == "checkout.session.completed":
|
||||
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":
|
||||
await _handle_stripe_subscription_invoice_paid(event)
|
||||
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):
|
||||
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")
|
||||
alan_action = event_object.get("metadata", {}).get("alan_action")
|
||||
lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
|
||||
logger.debug(
|
||||
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
|
||||
f" Payment hash: '{payment_hash}'."
|
||||
)
|
||||
if alan_action != "invoice":
|
||||
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.")
|
||||
if lnbits_action != "invoice":
|
||||
logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
|
||||
return
|
||||
|
||||
if not payment_hash:
|
||||
@@ -111,7 +76,7 @@ async def _handle_stripe_checkout_session_completed(event: dict):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
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):
|
||||
@@ -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(
|
||||
@@ -167,7 +132,7 @@ async def _get_stripe_subscription_payment_options(
|
||||
|
||||
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'.")
|
||||
|
||||
if "extra" in metadata:
|
||||
@@ -178,105 +143,3 @@ async def _get_stripe_subscription_payment_options(
|
||||
metadata["extra"] = {}
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,35 +2,27 @@ import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from loguru import logger
|
||||
|
||||
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 (
|
||||
SimpleStatus,
|
||||
User,
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
@@ -40,11 +32,9 @@ from lnbits.core.services.extensions import (
|
||||
install_extension,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -79,11 +69,7 @@ async def api_install_extension(data: CreateExtension):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
ext_meta = ExtensionMeta(
|
||||
installed_release=release,
|
||||
admin_only=release.admin_only,
|
||||
super_user_only=release.super_user_only,
|
||||
)
|
||||
ext_meta = ExtensionMeta(installed_release=release)
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id,
|
||||
name=data.ext_id,
|
||||
@@ -102,16 +88,12 @@ async def api_install_extension(data: CreateExtension):
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
if isinstance(exc, AssertionError)
|
||||
else f"Failed to install extension '{ext_info.id}'."
|
||||
f"({ext_info.installed_version})."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
HTTPStatus.BAD_REQUEST
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
@@ -160,10 +142,9 @@ async def api_extension_details(
|
||||
async def api_update_pay_to_enable(
|
||||
ext_id: str,
|
||||
data: PayToEnableInfo,
|
||||
account: Account = Depends(check_admin),
|
||||
user: User = Depends(check_admin),
|
||||
) -> 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(
|
||||
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
|
||||
)
|
||||
@@ -180,22 +161,9 @@ async def api_update_pay_to_enable(
|
||||
)
|
||||
|
||||
|
||||
def _check_enable_extension_access(
|
||||
ext_id: str, ext: InstallableExtension, account_id: AccountId
|
||||
) -> None:
|
||||
if ext.is_super_user_only and not settings.is_super_user(account_id.id):
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
|
||||
)
|
||||
if ext.is_admin_only and not settings.is_admin_user(account_id.id):
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
|
||||
)
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/enable")
|
||||
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:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
@@ -208,14 +176,13 @@ async def api_enable_extension(
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
_check_enable_extension_access(ext_id, ext, account_id)
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
if user.admin or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
@@ -252,13 +219,13 @@ async def api_enable_extension(
|
||||
|
||||
@extension_router.put("/{ext_id}/disable")
|
||||
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:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
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:
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Extension '{ext_id}' already disabled."
|
||||
@@ -409,9 +376,7 @@ async def get_pay_to_install_invoice(
|
||||
|
||||
@extension_router.put("/{ext_id}/invoice/enable")
|
||||
async def get_pay_to_enable_invoice(
|
||||
ext_id: str,
|
||||
data: PayToEnableInfo,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
||||
):
|
||||
if not data.amount or data.amount <= 0:
|
||||
raise HTTPException(
|
||||
@@ -457,9 +422,9 @@ async def get_pay_to_enable_invoice(
|
||||
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:
|
||||
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)
|
||||
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
|
||||
user_ext_info.payment_hash_to_enable = payment.payment_hash
|
||||
@@ -470,7 +435,7 @@ async def get_pay_to_enable_invoice(
|
||||
|
||||
@extension_router.get(
|
||||
"/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):
|
||||
try:
|
||||
@@ -482,8 +447,6 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
"admin_only": config.admin_only,
|
||||
"super_user_only": config.super_user_only,
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
@@ -493,18 +456,15 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
|
||||
@extension_router.get("")
|
||||
async def api_get_user_extensions(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> list[Extension]:
|
||||
async with db.connect() as conn:
|
||||
user_extensions_ids = [
|
||||
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn)
|
||||
]
|
||||
valid_extensions = [
|
||||
ext
|
||||
for ext in await get_valid_extensions(False, conn=conn)
|
||||
if ext.code in user_extensions_ids
|
||||
]
|
||||
return valid_extensions
|
||||
|
||||
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
|
||||
return [
|
||||
ext
|
||||
for ext in await get_valid_extensions(False)
|
||||
if ext.code in user_extensions_ids
|
||||
]
|
||||
|
||||
|
||||
@extension_router.delete(
|
||||
@@ -538,22 +498,11 @@ async def delete_extension_db(ext_id: str):
|
||||
|
||||
# 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)
|
||||
|
||||
async def extensions(user: User = Depends(check_user_exists)):
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions()
|
||||
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 = await InstallableExtension.get_installable_extensions()
|
||||
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]
|
||||
|
||||
@@ -561,7 +510,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
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:
|
||||
if installed_ext.meta.pay_to_enable and not user.admin:
|
||||
# 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
|
||||
@@ -579,6 +528,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
|
||||
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
|
||||
db_versions = await get_db_versions()
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
@@ -593,8 +546,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.is_admin_only,
|
||||
"isSuperUserOnly": ext.is_super_user_only,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
@@ -621,49 +573,3 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
User,
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
@@ -15,7 +16,6 @@ from lnbits.core.models.extensions import (
|
||||
UserExtension,
|
||||
)
|
||||
from lnbits.core.models.extensions_builder import ExtensionData
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
install_extension,
|
||||
@@ -26,9 +26,9 @@ from lnbits.core.services.extensions_builder import (
|
||||
zip_directory,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
check_admin,
|
||||
check_extension_builder,
|
||||
check_user_exists,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -84,9 +84,9 @@ async def api_build_extension(data: ExtensionData) -> FileResponse:
|
||||
)
|
||||
async def api_deploy_extension(
|
||||
data: ExtensionData,
|
||||
account: Account = Depends(check_admin),
|
||||
user: User = Depends(check_admin),
|
||||
) -> 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"
|
||||
release, build_dir = await build_extension_from_data(
|
||||
data, stub_ext_id, working_dir_name
|
||||
@@ -110,9 +110,9 @@ async def api_deploy_extension(
|
||||
|
||||
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:
|
||||
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)
|
||||
elif not user_ext.active:
|
||||
user_ext.active = True
|
||||
@@ -128,10 +128,10 @@ async def api_deploy_extension(
|
||||
)
|
||||
async def api_preview_extension(
|
||||
data: ExtensionData,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> SimpleStatus:
|
||||
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)
|
||||
|
||||
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.wallets import WalletTypeInfo
|
||||
@@ -31,11 +30,6 @@ async def create_subscription(
|
||||
data: CreateFiatSubscription,
|
||||
key_type: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> FiatSubscriptionResponse:
|
||||
logger.debug(
|
||||
f"Creating subscription with provider '{provider}. '"
|
||||
f"Subscription ID '{data.subscription_id}'."
|
||||
)
|
||||
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if not fiat_provider:
|
||||
raise HTTPException(404, "Fiat provider not found")
|
||||
@@ -53,12 +47,6 @@ async def create_subscription(
|
||||
subscription_response = await fiat_provider.create_subscription(
|
||||
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
|
||||
|
||||
|
||||
@@ -87,28 +75,20 @@ async def cancel_subscription(
|
||||
)
|
||||
async def connection_token(provider: str):
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if not fiat_provider:
|
||||
raise HTTPException(status_code=404, detail="Fiat provider not found")
|
||||
|
||||
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:
|
||||
if provider == "stripe":
|
||||
if not isinstance(fiat_provider, StripeWallet):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Stripe returned no connection token"
|
||||
status_code=500, detail="Stripe wallet/provider not configured"
|
||||
)
|
||||
return {"secret": secret}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create connection token"
|
||||
) from e
|
||||
try:
|
||||
tok = await fiat_provider.create_terminal_connection_token()
|
||||
secret = tok.get("secret")
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Stripe returned no connection token"
|
||||
)
|
||||
return {"secret": secret}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create connection token"
|
||||
) from e
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, Request
|
||||
from fastapi import Cookie, Depends, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from lnurl import url_decode
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
@@ -23,7 +25,12 @@ from lnbits.decorators import (
|
||||
from lnbits.helpers import check_callback_url, template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import get_user
|
||||
from ...utils.exchange_rates import allowed_currencies
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
get_user,
|
||||
get_wallet,
|
||||
)
|
||||
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
@@ -35,51 +42,118 @@ async def favicon():
|
||||
return RedirectResponse(settings.lnbits_qr_logo)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
description="show wallet page",
|
||||
)
|
||||
async def get_user_wallet(
|
||||
request: Request,
|
||||
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
wal: UUID4 | None = Query(None),
|
||||
):
|
||||
if wal:
|
||||
wallet = await get_wallet(wal.hex)
|
||||
elif len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
user.wallets.append(wallet)
|
||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||
wallet = await get_wallet(lnbits_last_active_wallet)
|
||||
else:
|
||||
wallet = user.wallets[0]
|
||||
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
if wallet.user != user.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Not your wallet.",
|
||||
)
|
||||
context = {
|
||||
"user": user.json(),
|
||||
"wallet": wallet.json(),
|
||||
"wallet_name": wallet.name,
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
}
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallet.html",
|
||||
{**context, "ajax": _is_ajax_request(request)},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||
async def robots():
|
||||
data = "User-agent: *\nDisallow: /"
|
||||
data = """
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
"""
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/extensions/builder/preview/{ext_id}/{resource}",
|
||||
"/extensions/builder/preview/{ext_id}",
|
||||
name="extensions builder",
|
||||
dependencies=[Depends(check_extension_builder)],
|
||||
)
|
||||
async def extensions_builder_preview(
|
||||
request: Request,
|
||||
ext_id: str,
|
||||
resource: str | None = None,
|
||||
page_name: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> FileResponse:
|
||||
) -> HTMLResponse:
|
||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||
|
||||
file_name = "index"
|
||||
html_file_name = "index.html"
|
||||
if page_name == "public_page":
|
||||
file_name = "public_page"
|
||||
html_file_name = "public_page.html"
|
||||
|
||||
resource_path = Path(
|
||||
settings.extension_builder_working_dir_path,
|
||||
html_file_path = Path(
|
||||
"extension_builder_stub",
|
||||
ext_id,
|
||||
working_dir_name,
|
||||
ext_id,
|
||||
"static",
|
||||
"templates",
|
||||
ext_id,
|
||||
html_file_name,
|
||||
)
|
||||
|
||||
file_ext = ".vue" if resource == "template" else ".js"
|
||||
file_full_path = Path(resource_path, file_name + file_ext)
|
||||
html_file_full_path = Path(
|
||||
settings.extension_builder_working_dir_path, html_file_path
|
||||
)
|
||||
|
||||
if not file_full_path.is_file():
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
if not html_file_full_path.is_file():
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"err": f"Extension {ext_id} not found",
|
||||
"message": "Please 'Refresh Preview' first.",
|
||||
},
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
response = template_renderer().TemplateResponse(
|
||||
request,
|
||||
html_file_path.as_posix(),
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
response = FileResponse(file_full_path.absolute().as_posix())
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -180,8 +254,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
|
||||
|
||||
@generic_router.get("/payments")
|
||||
@generic_router.get("/wallet")
|
||||
@generic_router.get("/wallet/{wallet_id}")
|
||||
@generic_router.get("/wallets")
|
||||
@generic_router.get("/account")
|
||||
@generic_router.get("/extensions")
|
||||
@@ -192,15 +264,12 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
@generic_router.get(
|
||||
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
@generic_router.get(
|
||||
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"base.html",
|
||||
"index.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
},
|
||||
@@ -211,7 +280,7 @@ async def index(
|
||||
@generic_router.get("/node/public")
|
||||
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
|
||||
async def index_public(request: Request) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
|
||||
return template_renderer().TemplateResponse(request, "index_public.html")
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
@@ -272,3 +341,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={account.id}&wal={wallet.id}",
|
||||
)
|
||||
|
||||
|
||||
def _is_ajax_request(request: Request):
|
||||
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"
|
||||
|
||||
@@ -24,7 +24,7 @@ from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_base_invoice_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
@@ -36,8 +36,6 @@ lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
try:
|
||||
if "@" in lnurl: # lower case lightning addresses
|
||||
lnurl = lnurl.lower()
|
||||
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
|
||||
if isinstance(res, LnurlErrorResponse):
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
|
||||
@@ -50,7 +48,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurlscan/{code}",
|
||||
dependencies=[Depends(require_base_invoice_key)],
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
deprecated=True,
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
@@ -66,7 +64,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
||||
|
||||
@lnurl_router.post(
|
||||
"/api/v1/lnurlscan",
|
||||
dependencies=[Depends(require_base_invoice_key)],
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
| LnurlAuthResponse
|
||||
|
||||
@@ -18,7 +18,6 @@ from lnbits.core.crud.payments import (
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
@@ -36,16 +35,14 @@ from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
)
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.wallets import BaseWalletTypeInfo
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_base_admin_key,
|
||||
require_base_invoice_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
filter_dict_keys,
|
||||
@@ -70,6 +67,7 @@ from ..services import (
|
||||
perform_withdraw,
|
||||
settle_hold_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
)
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
@@ -84,9 +82,10 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(key_info.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=key_info.wallet.id,
|
||||
pending=True,
|
||||
@@ -102,10 +101,11 @@ async def api_payments(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(key_info.wallet.id)
|
||||
return await get_payments_history(key_info.wallet.id, group, filters)
|
||||
|
||||
|
||||
@@ -118,14 +118,14 @@ async def api_payments_history(
|
||||
async def api_payments_counting_stats(
|
||||
count_by: PaymentCountField = Query("tag"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
if account_id.is_admin_id:
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = account_id.id
|
||||
for_user_id = user.id
|
||||
|
||||
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||
|
||||
@@ -138,14 +138,14 @@ async def api_payments_counting_stats(
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
if account_id.is_admin_id:
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = account_id.id
|
||||
for_user_id = user.id
|
||||
|
||||
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||
|
||||
@@ -157,15 +157,15 @@ async def api_payments_wallets_stats(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_daily_stats(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
if account_id.is_admin_id:
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = account_id.id
|
||||
for_user_id = user.id
|
||||
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@@ -178,30 +178,18 @@ async def api_payments_daily_stats(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
recheck_pending: bool = Query(
|
||||
False, description="Force check and update of pending payments."
|
||||
),
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
) -> Page[Payment]:
|
||||
async with db.connect() as conn:
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=key_info.wallet.id,
|
||||
filters=filters,
|
||||
conn=conn,
|
||||
)
|
||||
if not recheck_pending:
|
||||
return page
|
||||
):
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=key_info.wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in page.data:
|
||||
if payment.pending:
|
||||
await update_pending_payment(payment)
|
||||
|
||||
payments = []
|
||||
for payment in page.data:
|
||||
if payment.pending:
|
||||
refreshed_payment = await update_pending_payment(payment, conn=conn)
|
||||
payments.append(refreshed_payment)
|
||||
else:
|
||||
payments.append(payment)
|
||||
|
||||
return Page(data=payments, total=page.total)
|
||||
return page
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
@@ -214,19 +202,19 @@ async def api_payments_paginated(
|
||||
)
|
||||
async def api_all_payments_paginated(
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
if account_id.is_admin_id:
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = account_id.id
|
||||
for_user_id = user.id
|
||||
|
||||
async with db.connect() as conn:
|
||||
return await get_payments_paginated(
|
||||
filters=filters, user_id=for_user_id, conn=conn
|
||||
)
|
||||
return await get_payments_paginated(
|
||||
filters=filters,
|
||||
user_id=for_user_id,
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post(
|
||||
@@ -249,10 +237,10 @@ async def api_all_payments_paginated(
|
||||
)
|
||||
async def api_payments_create(
|
||||
invoice_data: CreateInvoice,
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Payment:
|
||||
wallet_id = key_info.wallet.id
|
||||
if invoice_data.out is True and key_info.key_type == KeyType.admin:
|
||||
wallet_id = wallet.wallet.id
|
||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||
if not invoice_data.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -280,8 +268,9 @@ async def api_payments_create(
|
||||
async def api_update_payment_labels(
|
||||
payment_hash: str,
|
||||
data: UpdatePaymentLabels,
|
||||
key_type: BaseWalletTypeInfo = Depends(require_base_admin_key),
|
||||
key_type: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> SimpleStatus:
|
||||
|
||||
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
|
||||
if payment is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
|
||||
@@ -337,7 +326,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||
return {"paid": False, "status": "failed"}
|
||||
|
||||
try:
|
||||
payment = await update_pending_payment(payment)
|
||||
status = await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
@@ -346,7 +335,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": payment.success,
|
||||
"status": f"{payment.status!s}",
|
||||
"status": f"{status!s}",
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ from fastapi import (
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from lnbits.core.models.wallets import BaseWalletTypeInfo
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_base_invoice_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -51,12 +50,12 @@ async def api_create_tinyurl(
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key)
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == key_info.wallet.id:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
|
||||
@@ -20,8 +20,6 @@ from lnbits.core.crud import (
|
||||
update_admin_settings,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.crud.users import clear_user_id_cache, get_account, update_account
|
||||
from lnbits.core.crud.wallets import delete_wallet_by_id
|
||||
from lnbits.core.models import (
|
||||
AccountFilters,
|
||||
AccountOverview,
|
||||
@@ -74,7 +72,7 @@ async def api_get_users(
|
||||
summary="Get user by Id",
|
||||
)
|
||||
async def api_get_user(user_id: str) -> User:
|
||||
user = await get_user(user_id, active_only=False)
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||
return user
|
||||
@@ -117,12 +115,12 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
||||
|
||||
@users_router.put("/user/{user_id}", name="Update user")
|
||||
async def api_update_user(
|
||||
user_id: str, data: CreateUser, account: Account = Depends(check_admin)
|
||||
user_id: str, data: CreateUser, user: User = Depends(check_admin)
|
||||
) -> CreateUser:
|
||||
if user_id != data.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
|
||||
|
||||
if user_id == settings.super_user and account.id != settings.super_user:
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
@@ -156,11 +154,14 @@ async def api_update_user(
|
||||
name="Delete user by Id",
|
||||
)
|
||||
async def api_users_delete_user(
|
||||
user_id: str, account: Account = Depends(check_admin)
|
||||
user_id: str, user: User = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
if len(wallets) > 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Cannot delete user with wallets.",
|
||||
)
|
||||
|
||||
if user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
@@ -168,7 +169,7 @@ async def api_users_delete_user(
|
||||
detail="Cannot delete super user.",
|
||||
)
|
||||
|
||||
if user_id in settings.lnbits_admin_users and not account.is_super_user:
|
||||
if user_id in settings.lnbits_admin_users and not user.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Only super_user can delete admin user.",
|
||||
@@ -198,7 +199,7 @@ async def api_users_reset_password(user_id: str) -> str:
|
||||
return f"reset_key_{reset_key_b64}"
|
||||
|
||||
|
||||
@users_router.put(
|
||||
@users_router.get(
|
||||
"/user/{user_id}/admin",
|
||||
dependencies=[Depends(check_super_user)],
|
||||
name="Give or revoke admin permsisions to a user",
|
||||
@@ -221,46 +222,9 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
||||
)
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/user/{user_id}/activate",
|
||||
name="Activate or deactivate a user",
|
||||
)
|
||||
async def api_users_toggle_activated(
|
||||
user_id: str, admin_account: Account = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
if user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Cannot deactivate super user.",
|
||||
)
|
||||
if user_id == admin_account.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="You cannot deactivate yourself.",
|
||||
)
|
||||
|
||||
if settings.is_admin_user(user_id):
|
||||
settings.lnbits_admin_users.remove(user_id)
|
||||
|
||||
user_account = await get_account(user_id, active_only=False)
|
||||
if not user_account:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="User not found.",
|
||||
)
|
||||
user_account.activated = not user_account.activated
|
||||
await update_account(user_account)
|
||||
await clear_user_id_cache(user_id)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"User {'activated' if user_account.activated else 'deactivated'}.",
|
||||
)
|
||||
|
||||
|
||||
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
||||
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
|
||||
return await get_wallets(user_id, deleted=None)
|
||||
return await get_wallets(user_id)
|
||||
|
||||
|
||||
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
||||
@@ -283,7 +247,7 @@ async def api_users_create_user_wallet(
|
||||
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
|
||||
)
|
||||
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
|
||||
wal = await get_wallet(wallet, deleted=True)
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
@@ -331,7 +295,7 @@ async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
|
||||
"The second time it is called will delete the entry from the DB",
|
||||
)
|
||||
async def api_users_delete_user_wallet(
|
||||
user_id: str, wallet: str, account: Account = Depends(check_admin)
|
||||
user_id: str, wallet: str, user: User = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
@@ -340,7 +304,7 @@ async def api_users_delete_user_wallet(
|
||||
detail="Wallet does not exist.",
|
||||
)
|
||||
|
||||
if user_id == settings.super_user and account.id != settings.super_user:
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
|
||||
@@ -9,14 +9,12 @@ from fastapi import (
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_wallets_paginated,
|
||||
)
|
||||
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
|
||||
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.models.wallets import (
|
||||
WalletsFilters,
|
||||
WalletSharePermission,
|
||||
@@ -31,8 +29,7 @@ from lnbits.core.services.wallets import (
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
@@ -68,11 +65,11 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
||||
openapi_extra=generate_filter_params_openapi(WalletsFilters),
|
||||
)
|
||||
async def api_wallets_paginated(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters = Depends(parse_filters(WalletsFilters)),
|
||||
):
|
||||
page = await get_wallets_paginated(
|
||||
user_id=account_id.id,
|
||||
user_id=user.id,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
@@ -88,7 +85,7 @@ async def api_invite_wallet_share(
|
||||
|
||||
@wallet_router.delete("/share/invite/{share_request_id}")
|
||||
async def api_reject_wallet_invitation(
|
||||
share_request_id: str, invited_user: Account = Depends(check_account_exists)
|
||||
share_request_id: str, invited_user: User = Depends(check_user_exists)
|
||||
) -> SimpleStatus:
|
||||
await reject_wallet_invitation(invited_user.id, share_request_id)
|
||||
return SimpleStatus(success=True, message="Invitation rejected.")
|
||||
@@ -127,15 +124,12 @@ async def api_update_wallet_name(
|
||||
|
||||
@wallet_router.put("/reset/{wallet_id}")
|
||||
async def api_reset_wallet_keys(
|
||||
wallet_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
clear_wallet_cache(wallet)
|
||||
|
||||
wallet.adminkey = uuid4().hex
|
||||
wallet.inkey = uuid4().hex
|
||||
await update_wallet(wallet)
|
||||
@@ -181,10 +175,10 @@ async def api_update_wallet(
|
||||
|
||||
@wallet_router.delete("/{wallet_id}")
|
||||
async def api_delete_wallet(
|
||||
wallet_id: str, account_id: AccountId = Depends(check_account_id_exists)
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
) -> None:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
await delete_wallet(
|
||||
@@ -195,14 +189,11 @@ async def api_delete_wallet(
|
||||
|
||||
@wallet_router.post("")
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet, account_id: AccountId = Depends(check_account_id_exists)
|
||||
data: CreateWallet,
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
|
||||
if data.wallet_type not in list(WalletType):
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Wallet type {data.wallet_type} does not exist.",
|
||||
)
|
||||
if data.wallet_type == WalletType.LIGHTNING:
|
||||
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
|
||||
|
||||
if data.wallet_type == WalletType.LIGHTNING_SHARED:
|
||||
if not data.shared_wallet_id:
|
||||
@@ -211,9 +202,11 @@ async def api_create_wallet(
|
||||
"Shared wallet ID is required for shared wallets.",
|
||||
)
|
||||
return await create_lightning_shared_wallet(
|
||||
user_id=account_id.id,
|
||||
user_id=key_info.wallet.user,
|
||||
source_wallet_id=data.shared_wallet_id,
|
||||
)
|
||||
|
||||
# default WalletType.LIGHTNING:
|
||||
return await create_wallet(user_id=account_id.id, wallet_name=data.name)
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Unknown wallet type: {data.wallet_type}.",
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ from lnbits.core.models import (
|
||||
CreateWebPushSubscription,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> WebPushSubscription:
|
||||
try:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, account_id.id)
|
||||
subscription = await get_webpush_subscription(endpoint, user.id)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
account_id.id,
|
||||
user.id,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
@@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
try:
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
count = await delete_webpush_subscription(endpoint, account_id.id)
|
||||
count = await delete_webpush_subscription(endpoint, user.id)
|
||||
return {"count": count}
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
+52
-44
@@ -12,6 +12,7 @@ from typing import Any, Generic, Literal, TypeVar, get_origin
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ValidationError, root_validator
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
@@ -55,6 +56,14 @@ def compat_timestamp_placeholder(key: str):
|
||||
return f":{key}"
|
||||
|
||||
|
||||
def get_placeholder(model: Any, field: str) -> str:
|
||||
type_ = model.__fields__[field].type_
|
||||
if type_ == datetime:
|
||||
return compat_timestamp_placeholder(field)
|
||||
else:
|
||||
return f":{field}"
|
||||
|
||||
|
||||
class Compat:
|
||||
type: str | None = "<inherited>"
|
||||
schema: str | None = "<inherited>"
|
||||
@@ -226,14 +235,11 @@ class Connection(Compat):
|
||||
table_name: if provided some optimisations can be applied.
|
||||
"""
|
||||
|
||||
if table_name and not _valid_sql_name(table_name):
|
||||
raise ValueError(f"Invalid table name: '{table_name}'.")
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
if table_name:
|
||||
if not _valid_sql_name(table_name):
|
||||
raise ValueError(f"Invalid table name: '{table_name}'.")
|
||||
filters.set_table_name(table_name)
|
||||
|
||||
clause = filters.where(where)
|
||||
parsed_values = filters.values(values)
|
||||
|
||||
@@ -317,7 +323,30 @@ class Database(Compat):
|
||||
self.engine: AsyncEngine = create_async_engine(
|
||||
database_uri, echo=settings.debug_database
|
||||
)
|
||||
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
|
||||
@event.listens_for(self.engine.sync_engine, "connect")
|
||||
def register_custom_types(dbapi_connection, *_):
|
||||
def _parse_date(value) -> datetime:
|
||||
if value is None:
|
||||
value = "1970-01-01 00:00:00"
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if "." not in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
return datetime.strptime(value, f)
|
||||
|
||||
dbapi_connection.run_async(
|
||||
lambda connection: connection.set_type_codec(
|
||||
"TIMESTAMP",
|
||||
encoder=datetime,
|
||||
decoder=_parse_date,
|
||||
schema="pg_catalog",
|
||||
)
|
||||
)
|
||||
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -462,7 +491,6 @@ class Page(BaseModel, Generic[T]):
|
||||
|
||||
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
table_name: str | None = None
|
||||
field: str
|
||||
op: Operator = Operator.EQ
|
||||
model: type[TFilterModel] | None
|
||||
@@ -490,7 +518,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values: dict = {}
|
||||
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
if op in {Operator.EVERY, Operator.ANY}:
|
||||
raw_values = [v for rv in raw_values for v in rv.split(",")]
|
||||
|
||||
for index, raw_value in enumerate(raw_values):
|
||||
@@ -505,23 +533,20 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
@property
|
||||
def statement(self) -> str:
|
||||
prefix = f"{self.table_name}." if self.table_name else ""
|
||||
stmt = []
|
||||
for key in self.values.keys() if self.values else []:
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
||||
clean_key = key.split("__")[0]
|
||||
if self.model and self.model.__fields__[clean_key].type_ == datetime:
|
||||
placeholder = compat_timestamp_placeholder(key)
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(f":{key}")
|
||||
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
|
||||
else:
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
|
||||
stmt.append(f"{clean_key} {self.op.as_sql} :{key}")
|
||||
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
||||
elif self.op == Operator.EVERY:
|
||||
if self.op == Operator.EVERY:
|
||||
statement = " AND ".join(stmt)
|
||||
else:
|
||||
statement = " OR ".join(stmt)
|
||||
|
||||
return f"({statement})"
|
||||
|
||||
|
||||
@@ -538,14 +563,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
search: str | None = None
|
||||
|
||||
offset: int | None = None
|
||||
limit: int | None = 10
|
||||
limit: int | None = None
|
||||
|
||||
sortby: str | None = None
|
||||
direction: Literal["asc", "desc"] | None = None
|
||||
|
||||
model: type[TFilterModel] | None = None
|
||||
|
||||
table_name: str | None = None
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_sortby(cls, values):
|
||||
sortby = values.get("sortby")
|
||||
@@ -560,8 +584,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def pagination(self) -> str:
|
||||
stmt = ""
|
||||
self.limit = self.limit or 10
|
||||
stmt += f"LIMIT {min(1000, self.limit)} "
|
||||
if self.limit:
|
||||
stmt += f"LIMIT {self.limit} "
|
||||
if self.offset:
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
return stmt
|
||||
@@ -587,8 +611,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def order_by(self) -> str:
|
||||
if self.sortby:
|
||||
prefix = f"{self.table_name}." if self.table_name else ""
|
||||
return f"ORDER BY {prefix}{self.sortby} {self.direction or 'asc'}"
|
||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||
return ""
|
||||
|
||||
def values(self, values: dict | None = None) -> dict:
|
||||
@@ -608,17 +631,6 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
values["search"] = f"%{self.search.lower()}%"
|
||||
return values
|
||||
|
||||
def set_table_name(self, table_name: str) -> None:
|
||||
self.table_name = table_name
|
||||
for page_filter in self.filters:
|
||||
page_filter.table_name = table_name
|
||||
|
||||
def get_filter_by_field(self, field: str) -> Filter[TFilterModel] | None:
|
||||
return next((f for f in self.filters if f.field == field), None)
|
||||
|
||||
def remove_filter_by_field(self, field: str) -> None:
|
||||
self.filters = [f for f in self.filters if f.field != field]
|
||||
|
||||
|
||||
class DbJsonEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
@@ -636,7 +648,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
placeholders = []
|
||||
keys = model_to_dict(model).keys()
|
||||
for field in keys:
|
||||
placeholders.append(f":{field}")
|
||||
placeholders.append(get_placeholder(model, field))
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields = ", ".join([f'"{key}"' for key in keys])
|
||||
values = ", ".join(placeholders)
|
||||
@@ -654,8 +666,9 @@ def update_query(
|
||||
"""
|
||||
fields = []
|
||||
for field in model_to_dict(model).keys():
|
||||
placeholder = get_placeholder(model, field)
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields.append(f'"{field}" = :{field}')
|
||||
fields.append(f'"{field}" = {placeholder}')
|
||||
query = ", ".join(fields)
|
||||
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
|
||||
|
||||
@@ -673,12 +686,7 @@ def model_to_dict(model: BaseModel) -> dict:
|
||||
if model.__fields__[key].field_info.extra.get("no_database", False):
|
||||
continue
|
||||
if isinstance(value, datetime):
|
||||
if DB_TYPE == SQLITE:
|
||||
_dict[key] = value.timestamp()
|
||||
else:
|
||||
# remove tz. postgres and cockroach TIMESTAMP is not tz aware
|
||||
# so it will throw if we dont remove the UTC.
|
||||
_dict[key] = value.replace(tzinfo=None)
|
||||
_dict[key] = value.timestamp()
|
||||
continue
|
||||
if (
|
||||
type(type_) is type(BaseModel)
|
||||
@@ -734,7 +742,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
||||
if DB_TYPE == SQLITE:
|
||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||
else:
|
||||
_dict[key] = value.replace(tzinfo=timezone.utc)
|
||||
_dict[key] = value
|
||||
continue
|
||||
if issubclass(type_, BaseModel):
|
||||
_dict[key] = dict_to_submodel(type_, value)
|
||||
|
||||
+54
-236
@@ -14,14 +14,11 @@ from lnbits.core.crud import (
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
get_installed_extension,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_from_account,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user_access_control_lists
|
||||
from lnbits.core.crud.wallets import get_base_wallet_for_key
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
AccessTokenPayload,
|
||||
Account,
|
||||
@@ -30,12 +27,9 @@ from lnbits.core.models import (
|
||||
User,
|
||||
WalletTypeInfo,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.wallets import BaseWallet, BaseWalletTypeInfo
|
||||
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||
from lnbits.helpers import normalize_path, path_segments, sha256s
|
||||
from lnbits.helpers import normalize_path, path_segments
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="api/v1/auth",
|
||||
@@ -58,7 +52,7 @@ api_key_query = APIKeyQuery(
|
||||
)
|
||||
|
||||
|
||||
class BaseKeyChecker(SecurityBase):
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
@@ -83,7 +77,8 @@ class BaseKeyChecker(SecurityBase):
|
||||
)
|
||||
self.model: APIKey = openapi_model # type: ignore
|
||||
|
||||
def _extract_key_value(self, request):
|
||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||
|
||||
key_value = (
|
||||
self._api_key
|
||||
if self._api_key
|
||||
@@ -96,88 +91,27 @@ class BaseKeyChecker(SecurityBase):
|
||||
detail="No Api Key provided.",
|
||||
)
|
||||
|
||||
return key_value
|
||||
wallet = await get_wallet_for_key(key_value)
|
||||
|
||||
async def _extract_key_type(self, key_value: str, wallet: BaseWallet) -> KeyType:
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
request.scope["user_id"] = wallet.user
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
await _check_user_extension_access(wallet.user, request["path"])
|
||||
|
||||
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
|
||||
return key_type
|
||||
|
||||
|
||||
class KeyChecker(BaseKeyChecker):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
expected_key_type: KeyType | None = None,
|
||||
):
|
||||
super().__init__(api_key, expected_key_type)
|
||||
|
||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||
key_value = self._extract_key_value(request)
|
||||
|
||||
async with db.connect() as conn:
|
||||
wallet = await get_wallet_for_key(key_value, conn=conn)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
request.scope["user_id"] = wallet.user
|
||||
await _check_user_access(request, wallet.user, conn=conn)
|
||||
|
||||
key_type = await self._extract_key_type(key_value, wallet)
|
||||
return WalletTypeInfo(key_type, wallet)
|
||||
|
||||
|
||||
class LightKeyChecker(BaseKeyChecker):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
expected_key_type: KeyType | None = None,
|
||||
):
|
||||
super().__init__(api_key, expected_key_type)
|
||||
|
||||
async def __call__(self, request: Request) -> BaseWalletTypeInfo:
|
||||
key_value = self._extract_key_value(request)
|
||||
cache_key = f"auth:x-api-key:{key_value}"
|
||||
cache_time = settings.auth_authentication_cache_minutes * 60
|
||||
|
||||
async with db.connect() as conn:
|
||||
if cache_time > 0:
|
||||
key_info: BaseWalletTypeInfo | None = cache.get(cache_key)
|
||||
if key_info:
|
||||
request.scope["user_id"] = key_info.wallet.user
|
||||
await _check_user_access(request, key_info.wallet.user, conn=conn)
|
||||
return key_info
|
||||
|
||||
wallet = await get_base_wallet_for_key(key_value, conn=conn)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
request.scope["user_id"] = wallet.user
|
||||
await _check_user_access(request, wallet.user, conn=conn)
|
||||
|
||||
key_type = await self._extract_key_type(key_value, wallet)
|
||||
key_info = BaseWalletTypeInfo(key_type, wallet)
|
||||
|
||||
if cache_time > 0:
|
||||
cache.set(cache_key, key_info, expiry=cache_time)
|
||||
cache.set(f"auth:wallet:{wallet.id}", wallet, expiry=cache_time)
|
||||
return key_info
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
@@ -190,18 +124,6 @@ async def require_admin_key(
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_base_admin_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> BaseWalletTypeInfo:
|
||||
check: LightKeyChecker = LightKeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.admin,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_invoice_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
@@ -214,18 +136,6 @@ async def require_invoice_key(
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_base_invoice_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> BaseWalletTypeInfo:
|
||||
check: LightKeyChecker = LightKeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.invoice,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def check_access_token(
|
||||
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
|
||||
cookie_access_token: Annotated[str | None, Cookie()] = None,
|
||||
@@ -234,96 +144,33 @@ async def check_access_token(
|
||||
return header_access_token or cookie_access_token or bearer_access_token
|
||||
|
||||
|
||||
async def check_account_id_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> AccountId:
|
||||
cache_key: str | None = None
|
||||
if access_token:
|
||||
cache_key = f"auth:access_token:{sha256s(access_token)}"
|
||||
elif usr:
|
||||
cache_key = f"auth:user_id:{sha256s(usr.hex)}"
|
||||
|
||||
async with db.connect() as conn:
|
||||
if cache_key and settings.auth_authentication_cache_minutes > 0:
|
||||
account_id = cache.get(cache_key)
|
||||
if account_id:
|
||||
r.scope["user_id"] = account_id.id
|
||||
await _check_user_access(r, account_id.id, conn=conn)
|
||||
return account_id
|
||||
|
||||
account = await _check_account_exists(r, access_token, usr, conn=conn)
|
||||
account_id = AccountId(id=account.id)
|
||||
|
||||
if cache_key and settings.auth_authentication_cache_minutes > 0:
|
||||
cache.set(
|
||||
cache_key,
|
||||
account_id,
|
||||
expiry=settings.auth_authentication_cache_minutes * 60,
|
||||
)
|
||||
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
|
||||
|
||||
return account_id
|
||||
|
||||
|
||||
async def check_account_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> Account:
|
||||
return await _check_account_exists(r, access_token, usr)
|
||||
|
||||
|
||||
async def _check_account_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Account:
|
||||
"""
|
||||
Check that the account exists based on access token or user id.
|
||||
More performant version of `check_user_exists`.
|
||||
Unlike `check_user_exists`, this function:
|
||||
- does not fetch the user wallets
|
||||
- caches the account info based on settings cache time
|
||||
"""
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(
|
||||
access_token, r["path"], r["method"], conn=new_conn
|
||||
)
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex, conn=new_conn)
|
||||
if account and account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Missing user ID or access token."
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
await _check_user_access(r, account.id, conn=new_conn)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
async def check_user_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> User:
|
||||
async with db.connect() as conn:
|
||||
account = await _check_account_exists(r, access_token, usr, conn=conn)
|
||||
user = await get_user_from_account(account, conn=conn)
|
||||
if not user:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex)
|
||||
if account and account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
if not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
await _check_user_extension_access(user.id, r["path"])
|
||||
return user
|
||||
|
||||
|
||||
@@ -351,36 +198,29 @@ async def access_token_payload(
|
||||
return AccessTokenPayload(**payload)
|
||||
|
||||
|
||||
async def check_admin(
|
||||
account: Annotated[Account, Depends(check_account_exists)],
|
||||
) -> Account:
|
||||
if (
|
||||
account.id != settings.super_user
|
||||
and account.id not in settings.lnbits_admin_users
|
||||
):
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
|
||||
)
|
||||
if not account.has_password:
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
|
||||
)
|
||||
|
||||
return account
|
||||
return user
|
||||
|
||||
|
||||
async def check_super_user(
|
||||
account: Annotated[Account, Depends(check_admin)],
|
||||
) -> Account:
|
||||
if account.id != settings.super_user:
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||
)
|
||||
if not account.has_password:
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
|
||||
)
|
||||
return account
|
||||
return user
|
||||
|
||||
|
||||
def parse_filters(model: type[TFilterModel]):
|
||||
@@ -425,19 +265,7 @@ async def check_user_extension_access(
|
||||
Check if the user has access to a particular extension.
|
||||
Raises HTTP Forbidden if the user is not allowed.
|
||||
"""
|
||||
ext = await get_installed_extension(ext_id, conn=conn)
|
||||
is_admin_only = settings.is_admin_extension(ext_id) or bool(
|
||||
ext and ext.meta and ext.meta.admin_only
|
||||
)
|
||||
is_super_user_only = bool(ext and ext.meta and ext.meta.super_user_only)
|
||||
|
||||
if is_super_user_only and not settings.is_super_user(user_id):
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"User not authorized for extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
if is_admin_only and not settings.is_admin_user(user_id):
|
||||
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
|
||||
return SimpleStatus(
|
||||
success=False, message=f"User not authorized for extension '{ext_id}'."
|
||||
)
|
||||
@@ -452,17 +280,9 @@ async def check_user_extension_access(
|
||||
return SimpleStatus(success=True, message="OK")
|
||||
|
||||
|
||||
async def _check_user_access(r: Request, user_id: str, conn: Connection | None = None):
|
||||
if not settings.is_user_allowed(user_id):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
await _check_user_extension_access(user_id, r["path"], conn=conn)
|
||||
|
||||
|
||||
async def _check_user_extension_access(
|
||||
user_id: str, path: str, conn: Connection | None = None
|
||||
):
|
||||
async def _check_user_extension_access(user_id: str, path: str):
|
||||
ext_id = path_segments(path)[0]
|
||||
status = await check_user_extension_access(user_id, ext_id, conn=conn)
|
||||
status = await check_user_extension_access(user_id, ext_id)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
@@ -471,12 +291,12 @@ async def _check_user_extension_access(
|
||||
|
||||
|
||||
async def _get_account_from_token(
|
||||
access_token: str, path: str, method: str, conn: Connection | None = None
|
||||
access_token: str, path: str, method: str
|
||||
) -> Account | None:
|
||||
try:
|
||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||
return await _get_account_from_jwt_payload(
|
||||
AccessTokenPayload(**payload), path, method, conn=conn
|
||||
AccessTokenPayload(**payload), path, method
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
@@ -489,35 +309,33 @@ async def _get_account_from_token(
|
||||
|
||||
|
||||
async def _get_account_from_jwt_payload(
|
||||
payload: AccessTokenPayload, path: str, method: str, conn: Connection | None = None
|
||||
payload: AccessTokenPayload, path: str, method: str
|
||||
) -> Account | None:
|
||||
account = None
|
||||
if payload.sub:
|
||||
account = await get_account_by_username(payload.sub, conn=conn)
|
||||
account = await get_account_by_username(payload.sub)
|
||||
elif payload.usr:
|
||||
account = await get_account(payload.usr, conn=conn)
|
||||
account = await get_account(payload.usr)
|
||||
elif payload.email:
|
||||
account = await get_account_by_email(payload.email, conn=conn)
|
||||
account = await get_account_by_email(payload.email)
|
||||
|
||||
if not account:
|
||||
return None
|
||||
|
||||
if payload.api_token_id:
|
||||
await _check_account_api_access(
|
||||
account.id, payload.api_token_id, path, method, conn=conn
|
||||
)
|
||||
await _check_account_api_access(account.id, payload.api_token_id, path, method)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
async def _check_account_api_access(
|
||||
user_id: str, token_id: str, path: str, method: str, conn: Connection | None = None
|
||||
user_id: str, token_id: str, path: str, method: str
|
||||
):
|
||||
segments = path.split("/")
|
||||
if len(segments) < 3:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
|
||||
|
||||
acls = await get_user_access_control_lists(user_id, conn=conn)
|
||||
acls = await get_user_access_control_lists(user_id)
|
||||
acl = acls.get_acl_by_token_id(token_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
|
||||
|
||||
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
|
||||
logger.error(f"RequestValidationError: {exc!s}")
|
||||
return render_html_error(request, exc) or JSONResponse(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": [dict(e) for e in exc.errors()]},
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
@@ -138,9 +138,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def error_handler_404(request: Request, exc: HTTPException):
|
||||
|
||||
if not request.url.path.endswith("routes.json"):
|
||||
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
|
||||
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
|
||||
|
||||
if not _is_browser_request(request):
|
||||
return JSONResponse(
|
||||
|
||||
@@ -8,7 +8,6 @@ from loguru import logger
|
||||
from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .paypal import PayPalWallet
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
@@ -16,7 +15,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
|
||||
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
paypal = "PayPalWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider | None:
|
||||
@@ -51,6 +49,5 @@ fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PayPalWallet",
|
||||
"StripeWallet",
|
||||
]
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
FiatInvoiceResponse,
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentResponse,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
FiatProvider,
|
||||
FiatStatusResponse,
|
||||
FiatSubscriptionPaymentOptions,
|
||||
FiatSubscriptionResponse,
|
||||
)
|
||||
|
||||
FiatMethod = Literal["checkout", "subscription"]
|
||||
|
||||
|
||||
class PayPalCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
cancel_url: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayPalSubscriptionOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checking_id: str | None = None
|
||||
payment_request: str | None = None
|
||||
|
||||
|
||||
class PayPalCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
fiat_method: FiatMethod = "checkout"
|
||||
checkout: PayPalCheckoutOptions | None = None
|
||||
subscription: PayPalSubscriptionOptions | None = None
|
||||
|
||||
|
||||
class PayPalWallet(FiatProvider):
|
||||
"""https://developer.paypal.com/api/rest/"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing PayPalWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.paypal_api_endpoint:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing endpoint.")
|
||||
if not settings.paypal_client_id:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing client id.")
|
||||
if not settings.paypal_client_secret:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing client secret.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.paypal_api_endpoint)
|
||||
self.headers = {
|
||||
"User-Agent": f"PayPal Alan:{settings.version}",
|
||||
}
|
||||
self._access_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("PayPalWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing PayPal wallet connection: {e}")
|
||||
|
||||
async def status(
|
||||
self, only_check_settings: bool | None = False
|
||||
) -> FiatStatusResponse:
|
||||
if only_check_settings:
|
||||
if self._settings_fields != self._settings_connection_fields():
|
||||
return FiatStatusResponse("Connection settings have changed.", 0)
|
||||
return FiatStatusResponse(balance=0)
|
||||
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
return FiatStatusResponse(balance=0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> FiatInvoiceResponse:
|
||||
opts = self._parse_create_opts(extra or {})
|
||||
if opts is None:
|
||||
return FiatInvoiceResponse(ok=False, error_message="Invalid PayPal options")
|
||||
if opts.fiat_method == "subscription":
|
||||
term = opts.subscription or PayPalSubscriptionOptions()
|
||||
checking_id = term.checking_id or urlsafe_short_hash()
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"subscription_{checking_id}",
|
||||
payment_request=term.payment_request or "",
|
||||
)
|
||||
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Unable to authenticate."
|
||||
)
|
||||
|
||||
co = opts.checkout or PayPalCheckoutOptions()
|
||||
success_url = (
|
||||
co.success_url
|
||||
or settings.paypal_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
cancel_url = co.cancel_url or success_url
|
||||
|
||||
order_data = {
|
||||
"intent": "CAPTURE",
|
||||
"purchase_units": [
|
||||
{
|
||||
"amount": {
|
||||
"currency_code": currency.upper(),
|
||||
"value": f"{amount:.2f}",
|
||||
},
|
||||
"custom_id": payment_hash[:127], # PayPal limit
|
||||
"invoice_id": payment_hash[:127],
|
||||
"description": memo or "LNbits Invoice",
|
||||
}
|
||||
],
|
||||
"application_context": {
|
||||
"return_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"shipping_preference": "NO_SHIPPING",
|
||||
"user_action": "PAY_NOW",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v2/checkout/orders", json=order_data, headers=self._auth_headers()
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
order_id = data.get("id")
|
||||
approval_url = self._get_approval_url(data.get("links") or [])
|
||||
if not order_id or not approval_url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing id or approval url"
|
||||
)
|
||||
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"fiat_paypal_{order_id}",
|
||||
payment_request=approval_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
quantity: int,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
success_url = (
|
||||
payment_options.success_url
|
||||
or settings.paypal_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
|
||||
logger.debug(f"Creating PayPal subscription with ID '{subscription_id}'")
|
||||
if not payment_options.subscription_request_id:
|
||||
payment_options.subscription_request_id = urlsafe_short_hash()
|
||||
payment_options.extra = payment_options.extra or {}
|
||||
payment_options.extra["subscription_request_id"] = (
|
||||
payment_options.subscription_request_id
|
||||
)
|
||||
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
payload = {
|
||||
"plan_id": subscription_id,
|
||||
"custom_id": self._serialize_metadata(payment_options),
|
||||
"application_context": {
|
||||
"return_url": success_url,
|
||||
"cancel_url": success_url,
|
||||
},
|
||||
}
|
||||
r = await self.client.post(
|
||||
"/v1/billing/subscriptions",
|
||||
json=payload,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
approval_url = self._get_approval_url(data.get("links") or [])
|
||||
if not approval_url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing approval url"
|
||||
)
|
||||
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=approval_url,
|
||||
subscription_request_id=data.get("id"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
correlation_id: str,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
logger.debug(
|
||||
f"Cancelling PayPal subscription '{subscription_id}'. "
|
||||
f"Correlation ID '{correlation_id}'."
|
||||
)
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
r = await self.client.post(
|
||||
f"/v1/billing/subscriptions/{subscription_id}/cancel",
|
||||
json={"reason": f"Cancelled by {correlation_id}"},
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return FiatSubscriptionResponse(ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Unable to cancel subscription."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||
raise NotImplementedError("PayPal does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
paypal_id = self._normalize_paypal_id(checking_id)
|
||||
if paypal_id.startswith("subscription_"):
|
||||
capture_id = paypal_id.replace("subscription_", "", 1)
|
||||
r = await self.client.get(
|
||||
f"v2/payments/captures/{capture_id}", headers=self._auth_headers()
|
||||
)
|
||||
r.raise_for_status()
|
||||
return self._status_from_capture(r.json())
|
||||
else:
|
||||
r = await self.client.get(
|
||||
f"/v2/checkout/orders/{paypal_id}", headers=self._auth_headers()
|
||||
)
|
||||
r.raise_for_status()
|
||||
return self._status_from_order(r.json())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting PayPal order status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("PayPal does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"PayPal does not support paid invoices stream. Use webhooks instead."
|
||||
)
|
||||
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
value = await mock_queue.get()
|
||||
yield value
|
||||
|
||||
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (order.get("status") or "").upper()
|
||||
if status in ["COMPLETED", "APPROVED"]:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
def _status_from_capture(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (order.get("status") or "").upper()
|
||||
if status in ["COMPLETED"]:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["DECLINED", "FAILED", "CANCELLED", "CANCELED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
def _normalize_paypal_id(self, checking_id: str) -> str:
|
||||
return (
|
||||
checking_id.replace("fiat_paypal_", "", 1)
|
||||
if checking_id.startswith("fiat_paypal_")
|
||||
else checking_id
|
||||
)
|
||||
|
||||
def _serialize_metadata(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> str:
|
||||
meta = [
|
||||
payment_options.wallet_id,
|
||||
payment_options.tag,
|
||||
payment_options.subscription_request_id,
|
||||
]
|
||||
if payment_options.extra:
|
||||
meta.append(payment_options.extra.get("link", None))
|
||||
|
||||
# Keep custom_id within PayPal's 127-char limit
|
||||
memo_limit = 120 - len(json.dumps(meta))
|
||||
if memo_limit > 0 and payment_options.memo:
|
||||
meta.append(payment_options.memo[:memo_limit])
|
||||
|
||||
return json.dumps(meta)
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> PayPalCreateInvoiceOptions | None:
|
||||
try:
|
||||
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid PayPal options: {e}")
|
||||
return None
|
||||
|
||||
async def _ensure_access_token(self):
|
||||
if self._access_token and time.time() < self._token_expires_at:
|
||||
return
|
||||
|
||||
r = await self.client.post(
|
||||
"/v1/oauth2/token",
|
||||
data={"grant_type": "client_credentials"},
|
||||
auth=(settings.paypal_client_id or "", settings.paypal_client_secret or ""),
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
token = data.get("access_token")
|
||||
expires_in = int(data.get("expires_in") or 300)
|
||||
if not token:
|
||||
raise ValueError("Unable to retrieve PayPal access token.")
|
||||
self._access_token = token
|
||||
self._token_expires_at = time.time() + expires_in - 30
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {**self.headers, "Authorization": f"Bearer {self._access_token}"}
|
||||
|
||||
def _get_approval_url(self, links: list[dict[str, Any]]) -> str | None:
|
||||
for link in links:
|
||||
if link.get("rel") == "approve":
|
||||
return link.get("href")
|
||||
return None
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.paypal_api_endpoint),
|
||||
str(settings.paypal_client_id),
|
||||
str(settings.paypal_client_secret),
|
||||
str(settings.paypal_webhook_id),
|
||||
]
|
||||
)
|
||||
+2
-23
@@ -35,7 +35,6 @@ class StripeTerminalOptions(BaseModel):
|
||||
|
||||
capture_method: Literal["automatic", "manual"] = "automatic"
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
reader_id: str | None = None
|
||||
|
||||
|
||||
class StripeCheckoutOptions(BaseModel):
|
||||
@@ -171,7 +170,7 @@ class StripeWallet(FiatProvider):
|
||||
("line_items[0][price]", subscription_id),
|
||||
("line_items[0][quantity]", f"{quantity}"),
|
||||
]
|
||||
subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
|
||||
subscription_data = {**payment_options.dict(), "lnbits_action": "subscription"}
|
||||
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
|
||||
|
||||
form_data += self._encode_metadata(
|
||||
@@ -296,15 +295,6 @@ class StripeWallet(FiatProvider):
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def _process_terminal_payment_intent(
|
||||
self, reader_id: str, payment_intent_id: str
|
||||
) -> None:
|
||||
data = {"payment_intent": payment_intent_id}
|
||||
r = await self.client.post(
|
||||
f"/v1/terminal/readers/{reader_id}/process_payment_intent", data=data
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def _create_checkout_invoice(
|
||||
self,
|
||||
amount_cents: int,
|
||||
@@ -325,7 +315,7 @@ class StripeWallet(FiatProvider):
|
||||
("mode", "payment"),
|
||||
("success_url", success_url),
|
||||
("metadata[payment_hash]", payment_hash),
|
||||
("metadata[alan_action]", "invoice"),
|
||||
("metadata[lnbits_action]", "invoice"),
|
||||
("line_items[0][price_data][currency]", currency.lower()),
|
||||
("line_items[0][price_data][product_data][name]", line_item_name),
|
||||
("line_items[0][price_data][unit_amount]", str(amount_cents)),
|
||||
@@ -388,17 +378,6 @@ class StripeWallet(FiatProvider):
|
||||
ok=False,
|
||||
error_message="Error: missing PaymentIntent or client_secret",
|
||||
)
|
||||
if term.reader_id:
|
||||
try:
|
||||
await self._process_terminal_payment_intent(term.reader_id, pi_id)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False,
|
||||
error_message=(
|
||||
"Error: unable to process PaymentIntent on reader"
|
||||
),
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True, checking_id=pi_id, payment_request=client_secret
|
||||
)
|
||||
|
||||
+50
-20
@@ -7,17 +7,17 @@ from typing import Any
|
||||
from urllib import request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi.routing import APIRoute
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
from starlette.templating import Jinja2Templates
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from .db import FilterModel
|
||||
|
||||
@@ -55,6 +55,7 @@ def static_url_for(static: str, path: str) -> str:
|
||||
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
||||
folders = [
|
||||
"lnbits/templates",
|
||||
"lnbits/core/templates",
|
||||
settings.extension_builder_working_dir_path.as_posix(),
|
||||
]
|
||||
|
||||
@@ -64,15 +65,55 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
for f in additional_folders
|
||||
]
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(directory=folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
t.env.globals["normalize_path"] = normalize_path
|
||||
|
||||
# used in base.html
|
||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
||||
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
|
||||
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
|
||||
t.env.globals["CURRENCIES"] = list(currencies.keys())
|
||||
window_settings = {
|
||||
"AD_SPACE": settings.lnbits_ad_space,
|
||||
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
|
||||
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
|
||||
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
|
||||
"HIDE_API": settings.lnbits_hide_api,
|
||||
"SITE_TITLE": settings.lnbits_site_title,
|
||||
"SITE_TAGLINE": settings.lnbits_site_tagline,
|
||||
"SITE_DESCRIPTION": settings.lnbits_site_description,
|
||||
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
||||
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
||||
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
|
||||
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
|
||||
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
|
||||
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
|
||||
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
||||
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
||||
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
|
||||
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
|
||||
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
|
||||
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
|
||||
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
|
||||
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
|
||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||
"LNBITS_VERSION": settings.version,
|
||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
"LNBITS_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||
"LNBITS_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||
"LNBITS_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||
"LNBITS_DENOMINATION": settings.lnbits_denomination,
|
||||
"has_holdinvoice": settings.has_holdinvoice,
|
||||
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
|
||||
"LNBITS_TELEGRAM_CONFIGURED": settings.is_telegram_notifications_configured(),
|
||||
"LNBITS_EXT_BUILDER": settings.lnbits_extensions_builder_activate_non_admins,
|
||||
}
|
||||
|
||||
t.env.globals["WINDOW_SETTINGS"] = window_settings
|
||||
for key, value in window_settings.items():
|
||||
t.env.globals[key] = value
|
||||
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
@@ -86,9 +127,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
||||
|
||||
# backwards compatibility for extensions (tpos)
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -346,14 +384,6 @@ def camel_to_snake(name: str) -> str:
|
||||
return name.lower()
|
||||
|
||||
|
||||
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
|
||||
components = name.split("_")
|
||||
camel = components[0] + "".join(x.capitalize() for x in components[1:])
|
||||
if capitalize_first:
|
||||
camel = camel[0].upper() + camel[1:]
|
||||
return camel
|
||||
|
||||
|
||||
def is_camel_case(v: str) -> bool:
|
||||
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import typing
|
||||
|
||||
from jinja2 import BaseLoader, Environment, pass_context
|
||||
from starlette.datastructures import QueryParams
|
||||
from starlette.requests import Request
|
||||
from starlette.templating import Jinja2Templates as SuperJinja2Templates
|
||||
|
||||
|
||||
class Jinja2Templates(SuperJinja2Templates):
|
||||
def __init__(self, loader: BaseLoader) -> None:
|
||||
self.env = self.get_environment(loader)
|
||||
super().__init__(env=self.env)
|
||||
|
||||
def get_environment(self, loader: BaseLoader) -> Environment:
|
||||
@pass_context
|
||||
def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
|
||||
request: Request = context["request"]
|
||||
return request.app.url_path_for(name, **path_params)
|
||||
|
||||
def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams:
|
||||
values = dict(init)
|
||||
values.update(new)
|
||||
return QueryParams(**values)
|
||||
|
||||
env = Environment(loader=loader, autoescape=True)
|
||||
env.globals["url_for"] = url_for
|
||||
env.globals["url_params_update"] = url_params_update
|
||||
return env
|
||||
@@ -76,7 +76,7 @@ class LndRestNode(Node):
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
return self.request("GET", path, timeout=30, **kwargs)
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.get("/v1/getinfo")
|
||||
@@ -99,12 +99,11 @@ class LndRestNode(Node):
|
||||
"perm": True,
|
||||
"timeout": 30,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id, timeout=30)
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||
except HTTPException as exc:
|
||||
if "unable to disconnect" in exc.detail:
|
||||
raise HTTPException(
|
||||
@@ -142,7 +141,6 @@ class LndRestNode(Node):
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
@@ -216,7 +214,6 @@ class LndRestNode(Node):
|
||||
# 'min_htlc_msat': <uint64>,
|
||||
# 'inbound_fee': <InboundFee>,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
|
||||
+5
-231
@@ -41,14 +41,6 @@ class UsersSettings(LNbitsSettings):
|
||||
lnbits_admin_users: list[str] = Field(default=[])
|
||||
lnbits_allowed_users: list[str] = Field(default=[])
|
||||
lnbits_allow_new_accounts: bool = Field(default=True)
|
||||
lnbits_require_user_activation: bool = Field(default=False)
|
||||
|
||||
lnbits_user_activation_by_email: bool = Field(default=False)
|
||||
lnbits_user_activation_by_payment: bool = Field(default=False)
|
||||
lnbits_user_activation_by_invitation_code: bool = Field(default=False)
|
||||
|
||||
lnbits_register_reusable_activation_code: str = Field(default="")
|
||||
lnbits_register_one_time_activation_codes: list[str] = Field(default=[])
|
||||
|
||||
@property
|
||||
def new_accounts_allowed(self) -> bool:
|
||||
@@ -60,13 +52,6 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
||||
lnbits_extensions_reviews_url: str = Field(
|
||||
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
|
||||
description="""
|
||||
URL for the paid reviews.
|
||||
Regular users can view this URL (not secret).
|
||||
""",
|
||||
)
|
||||
lnbits_extensions_manifests: list[str] = Field(
|
||||
default=[
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||
@@ -260,11 +245,6 @@ class ThemesSettings(LNbitsSettings):
|
||||
)
|
||||
lnbits_show_home_page_elements: bool = Field(default=True)
|
||||
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
||||
|
||||
lnbits_wallet_featured_button_label: str | None = Field(default=None)
|
||||
lnbits_wallet_featured_button_url: str | None = Field(default=None)
|
||||
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
|
||||
|
||||
lnbits_custom_badge: str | None = Field(default=None)
|
||||
lnbits_custom_badge_color: str = Field(default="warning")
|
||||
lnbits_theme_options: list[str] = Field(
|
||||
@@ -290,16 +270,11 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: str | None = Field(default=None)
|
||||
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
|
||||
lnbits_apple_touch_icon: str | None = Field(default=None)
|
||||
lnbits_default_reaction: str = Field(default="confettiBothSides")
|
||||
lnbits_default_theme: str = Field(default="bitcoin")
|
||||
lnbits_default_theme: str = Field(default="salvador")
|
||||
lnbits_default_border: str = Field(default="hard-border")
|
||||
lnbits_default_gradient: bool = Field(default=True)
|
||||
lnbits_default_bgimage: str | None = Field(default=None)
|
||||
lnbits_default_dark: bool = Field(default=True)
|
||||
lnbits_default_card_rounded: bool = Field(default=True)
|
||||
lnbits_default_card_gradient: bool = Field(default=True)
|
||||
lnbits_default_card_shadow: bool = Field(default=False)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -323,11 +298,6 @@ class AssetSettings(LNbitsSettings):
|
||||
"heic",
|
||||
"heif",
|
||||
"heics",
|
||||
"text/plain",
|
||||
"text/json",
|
||||
"text/xml",
|
||||
"application/json",
|
||||
"application/pdf",
|
||||
]
|
||||
)
|
||||
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
|
||||
@@ -337,12 +307,6 @@ class AssetSettings(LNbitsSettings):
|
||||
lnbits_max_assets_per_user: int = Field(default=1, ge=0)
|
||||
lnbits_assets_no_limit_users: list[str] = Field(default=[])
|
||||
|
||||
def is_unlimited_assets_user(self, user_id: str) -> bool:
|
||||
return (
|
||||
settings.is_admin_user(user_id)
|
||||
or user_id in self.lnbits_assets_no_limit_users
|
||||
)
|
||||
|
||||
|
||||
class FeeSettings(LNbitsSettings):
|
||||
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
|
||||
@@ -448,7 +412,6 @@ class SecuritySettings(LNbitsSettings):
|
||||
|
||||
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
first_install_token_confirmed: str | None = Field(default=None)
|
||||
|
||||
def is_wallet_max_balance_exceeded(self, amount):
|
||||
return (
|
||||
@@ -589,8 +552,6 @@ class ZBDFundingSource(LNbitsSettings):
|
||||
class PhoenixdFundingSource(LNbitsSettings):
|
||||
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
|
||||
phoenixd_api_password: str | None = Field(default=None)
|
||||
phoenixd_data_dir: str | None = Field(default=None)
|
||||
phoenixd_mnemonic: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
@@ -610,16 +571,6 @@ class SparkFundingSource(LNbitsSettings):
|
||||
spark_token: str | None = Field(default=None)
|
||||
|
||||
|
||||
class SparkL2FundingSource(LNbitsSettings):
|
||||
spark_l2_network: str = Field(default="MAINNET")
|
||||
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
|
||||
spark_l2_external_api_key: str | None = Field(default=None)
|
||||
spark_l2_mnemonic: str | None = Field(default=None)
|
||||
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
|
||||
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
|
||||
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
|
||||
|
||||
|
||||
class LnTipsFundingSource(LNbitsSettings):
|
||||
lntips_api_endpoint: str | None = Field(default=None)
|
||||
lntips_api_key: str | None = Field(default=None)
|
||||
@@ -649,6 +600,7 @@ class BreezLiquidSdkFundingSource(LNbitsSettings):
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: str | None = Field(default=None)
|
||||
boltz_client_wallet: str | None = Field(default="lnbits")
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
@@ -689,20 +641,6 @@ class StripeFiatProvider(LNbitsSettings):
|
||||
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class PayPalFiatProvider(LNbitsSettings):
|
||||
paypal_enabled: bool = Field(default=False)
|
||||
paypal_api_endpoint: str = Field(default="https://api-m.paypal.com")
|
||||
paypal_client_id: str | None = Field(default=None)
|
||||
paypal_client_secret: str | None = Field(default=None)
|
||||
paypal_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
paypal_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/paypal"
|
||||
)
|
||||
paypal_webhook_id: str | None = Field(default=None)
|
||||
|
||||
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -725,7 +663,6 @@ class FundingSourcesSettings(
|
||||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
SparkL2FundingSource,
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
@@ -736,11 +673,10 @@ class FundingSourcesSettings(
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
# It will not fail the payment, it will make it return pending after the timeout
|
||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
||||
lnbits_funding_source_pending_interval_seconds: int = Field(default=1800, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
class FiatProvidersSettings(StripeFiatProvider):
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
@@ -749,8 +685,7 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
return False
|
||||
if provider == "stripe":
|
||||
return self.stripe_enabled
|
||||
if provider == "paypal":
|
||||
return self.paypal_enabled
|
||||
# Add checks for other fiat providers here as needed
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
@@ -764,12 +699,7 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
):
|
||||
allowed_providers.append("stripe")
|
||||
|
||||
if self.paypal_enabled and (
|
||||
not self.paypal_limits.allowed_users
|
||||
or user_id in self.paypal_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("paypal")
|
||||
|
||||
# Add other fiat providers here as needed
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
@@ -801,7 +731,6 @@ class AuthMethods(Enum):
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
keycloak_auth = "keycloak-auth"
|
||||
oidc_auth = "oidc-auth"
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
@@ -812,7 +741,6 @@ class AuthMethods(Enum):
|
||||
AuthMethods.google_auth.value,
|
||||
AuthMethods.github_auth.value,
|
||||
AuthMethods.keycloak_auth.value,
|
||||
AuthMethods.oidc_auth.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -828,7 +756,6 @@ class AuthSettings(LNbitsSettings):
|
||||
# How many seconds after login the user is allowed to update its credentials.
|
||||
# A fresh login is required afterwards.
|
||||
auth_credetials_update_threshold: int = Field(default=120, gt=0)
|
||||
auth_authentication_cache_minutes: int = Field(default=10, ge=0)
|
||||
|
||||
def is_auth_method_allowed(self, method: AuthMethods):
|
||||
return method.value in self.auth_allowed_methods
|
||||
@@ -858,14 +785,6 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class OidcAuthSettings(LNbitsSettings):
|
||||
oidc_discovery_url: str = Field(default="")
|
||||
oidc_client_id: str = Field(default="")
|
||||
oidc_client_secret: str = Field(default="")
|
||||
oidc_client_custom_org: str | None = Field(default=None)
|
||||
oidc_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AuditSettings(LNbitsSettings):
|
||||
lnbits_audit_enabled: bool = Field(default=True)
|
||||
|
||||
@@ -973,7 +892,6 @@ class EditableSettings(
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
OidcAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -1009,8 +927,6 @@ class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
auth_https_only: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000, gt=0)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
@@ -1024,24 +940,14 @@ class EnvSettings(LNbitsSettings):
|
||||
enable_log_to_file: bool = Field(default=True)
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
first_install_token: str | None = Field(default=None)
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
lnbits_max_users: int = Field(default=0, ge=0)
|
||||
lnbits_max_extensions: int = Field(default=0, ge=0)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
return self.lnbits_extensions_path == "lnbits"
|
||||
|
||||
def has_first_install_token_changed(self) -> bool:
|
||||
if not self.first_install_token:
|
||||
return False
|
||||
if not settings.first_install_token_confirmed:
|
||||
return False
|
||||
return self.first_install_token != settings.first_install_token_confirmed
|
||||
|
||||
def check_auth_secret_key(self):
|
||||
if self.auth_secret_key:
|
||||
return
|
||||
@@ -1077,7 +983,6 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"FakeWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"SparkL2Wallet",
|
||||
"LnTipsWallet",
|
||||
"LndRestWallet",
|
||||
"LndWallet",
|
||||
@@ -1183,137 +1088,6 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
and ext_id in self.lnbits_all_extensions_ids
|
||||
)
|
||||
|
||||
def to_public(self) -> PublicSettings:
|
||||
return PublicSettings.from_settings(self)
|
||||
|
||||
|
||||
class PublicSettings(BaseModel):
|
||||
"""
|
||||
PublicSettings is used for templating and public information.
|
||||
WARNING: do not expose private information, PublicSettings is exposed publicly.
|
||||
"""
|
||||
|
||||
allow_register: bool = Field(alias="allowRegister")
|
||||
qr_logo: str = Field(alias="qrLogo")
|
||||
site_title: str = Field(alias="siteTitle")
|
||||
site_tagline: str = Field(alias="siteTagline")
|
||||
site_description: str | None = Field(alias="siteDescription")
|
||||
auth_methods: list[str] = Field(alias="authMethods")
|
||||
keycloak_org: str | None = Field(alias="keycloakOrg")
|
||||
keycloak_icon: str | None = Field(alias="keycloakIcon")
|
||||
oidc_org: str | None = Field(alias="oidcOrg")
|
||||
oidc_icon: str | None = Field(alias="oidcIcon")
|
||||
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
|
||||
has_nodemanager: bool = Field(alias="hasNodemanager")
|
||||
show_nodemanager: bool = Field(alias="showNodemanager")
|
||||
custom_logo: str | None = Field(alias="customLogo")
|
||||
show_voidwallet: bool = Field(alias="showVoidwallet")
|
||||
version: str = Field(alias="version")
|
||||
show_home_page_elements: bool = Field(alias="showHomePageElements")
|
||||
hide_api: bool = Field(alias="hideApi")
|
||||
cache_key: str = Field(alias="cacheKey")
|
||||
webpush_pubkey: str | None = Field(alias="webpushPubkey")
|
||||
show_extensions: bool = Field(alias="showExtensions")
|
||||
show_audit: bool = Field(alias="showAudit")
|
||||
show_admin: bool = Field(alias="showAdmin")
|
||||
ad_space: list[list[str]] = Field(alias="adSpace")
|
||||
ad_space_title: str = Field(alias="adSpaceTitle")
|
||||
show_ad_space: bool = Field(alias="showAdSpace")
|
||||
custom_image: str | None = Field(alias="customImage")
|
||||
custom_badge: str | None = Field(alias="customBadge")
|
||||
custom_badge_color: str | None = Field(alias="customBadgeColor")
|
||||
service_fee: float = Field(alias="serviceFee")
|
||||
service_fee_max: int = Field(alias="serviceFeeMax")
|
||||
service_fee_wallet: str | None = Field(alias="serviceFeeWallet")
|
||||
theme_options: list[str] = Field(alias="themeOptions")
|
||||
default_reaction: str = Field(alias="defaultReaction")
|
||||
default_theme: str = Field(alias="defaultTheme")
|
||||
default_border: str = Field(alias="defaultBorder")
|
||||
default_bgimage: str | None = Field(alias="defaultBgimage")
|
||||
default_gradient: bool = Field(alias="defaultGradient")
|
||||
default_dark: bool = Field(alias="defaultDark")
|
||||
default_card_rounded: bool = Field(alias="defaultCardRounded")
|
||||
default_card_gradient: bool = Field(alias="defaultCardGradient")
|
||||
default_card_shadow: bool = Field(alias="defaultCardShadow")
|
||||
denomination: str | None = Field()
|
||||
extensions: list[str] = Field()
|
||||
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
|
||||
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
|
||||
ext_builder: bool = Field(alias="extBuilder")
|
||||
nostr_configured: bool = Field(alias="nostrConfigured")
|
||||
telegram_configured: bool = Field(alias="telegramConfigured")
|
||||
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
||||
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
||||
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
|
||||
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
|
||||
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
|
||||
lnbits_user_activation_by_invitation_code: bool = Field(
|
||||
alias="userActivationByInvitationCode"
|
||||
)
|
||||
has_first_install_token: bool = Field(alias="hasFirstInstallToken")
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Settings):
|
||||
ads = [x.split(";") for x in settings.lnbits_ad_space.split(",")]
|
||||
return cls(
|
||||
adSpace=ads,
|
||||
adSpaceTitle=settings.lnbits_ad_space_title,
|
||||
showAdSpace=settings.lnbits_ad_space_enabled and len(ads) > 0,
|
||||
allowRegister=settings.new_accounts_allowed,
|
||||
qrLogo=settings.lnbits_qr_logo,
|
||||
siteDescription=settings.lnbits_site_description,
|
||||
siteTitle=settings.lnbits_site_title,
|
||||
siteTagline=settings.lnbits_site_tagline,
|
||||
authMethods=settings.auth_allowed_methods,
|
||||
keycloakOrg=settings.keycloak_client_custom_org,
|
||||
keycloakIcon=settings.keycloak_client_custom_icon,
|
||||
oidcOrg=settings.oidc_client_custom_org,
|
||||
oidcIcon=settings.oidc_client_custom_icon,
|
||||
hasHoldinvoice=settings.has_holdinvoice,
|
||||
hasNodemanager=settings.has_nodemanager,
|
||||
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
customLogo=settings.lnbits_custom_logo,
|
||||
showVoidwallet=settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
version=settings.version,
|
||||
showHomePageElements=settings.lnbits_show_home_page_elements,
|
||||
hideApi=settings.lnbits_hide_api,
|
||||
cacheKey=str(settings.server_startup_time),
|
||||
webpushPubkey=settings.lnbits_webpush_pubkey,
|
||||
showExtensions=not settings.lnbits_extensions_deactivate_all,
|
||||
showAudit=settings.lnbits_audit_enabled,
|
||||
showAdmin=settings.lnbits_admin_ui,
|
||||
customImage=settings.lnbits_custom_image,
|
||||
customBadge=settings.lnbits_custom_badge,
|
||||
customBadgeColor=settings.lnbits_custom_badge_color,
|
||||
serviceFee=settings.lnbits_service_fee,
|
||||
serviceFeeMax=settings.lnbits_service_fee_max,
|
||||
serviceFeeWallet=settings.lnbits_service_fee_wallet,
|
||||
themeOptions=settings.lnbits_theme_options,
|
||||
defaultReaction=settings.lnbits_default_reaction,
|
||||
defaultTheme=settings.lnbits_default_theme,
|
||||
defaultBorder=settings.lnbits_default_border,
|
||||
defaultGradient=settings.lnbits_default_gradient,
|
||||
defaultBgimage=settings.lnbits_default_bgimage,
|
||||
defaultDark=settings.lnbits_default_dark,
|
||||
defaultCardRounded=settings.lnbits_default_card_rounded,
|
||||
defaultCardGradient=settings.lnbits_default_card_gradient,
|
||||
defaultCardShadow=settings.lnbits_default_card_shadow,
|
||||
denomination=settings.lnbits_denomination,
|
||||
extensions=list(settings.lnbits_installed_extensions_ids),
|
||||
allowedCurrencies=settings.lnbits_allowed_currencies,
|
||||
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
|
||||
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
|
||||
nostrConfigured=settings.is_nostr_notifications_configured(),
|
||||
telegramConfigured=settings.is_telegram_notifications_configured(),
|
||||
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
||||
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
||||
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
|
||||
userActivationByEmail=settings.lnbits_user_activation_by_email,
|
||||
userActivationByPayment=settings.lnbits_user_activation_by_payment,
|
||||
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,
|
||||
hasFirstInstallToken=settings.first_install_token is not None,
|
||||
)
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17
-19
File diff suppressed because one or more lines are too long
+1
-185
@@ -217,182 +217,7 @@ body.body--dark .q-header,
|
||||
body.body--dark .q-drawer {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
}
|
||||
|
||||
body.rounded-ui .q-card,
|
||||
body.rounded-ui .q-btn {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body[data-theme=classic].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(103, 58, 183, 0.08), rgba(156, 39, 176, 0.06));
|
||||
}
|
||||
body[data-theme=classic].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(103, 58, 183, 0.06), rgba(156, 39, 176, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=classic].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(38.6192771084, 42.356626506, 64.7807228916), rgba(103, 58, 183, 0.07));
|
||||
}
|
||||
body[data-theme=classic].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(36.7144578313, 40.2674698795, 61.5855421687), rgba(103, 58, 183, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(234, 97, 29, 0.08), rgba(229, 111, 53, 0.06));
|
||||
}
|
||||
body[data-theme=bitcoin].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(234, 97, 29, 0.06), rgba(229, 111, 53, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(54.18, 49.364, 71.036), rgba(234, 97, 29, 0.07));
|
||||
}
|
||||
body[data-theme=bitcoin].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(51.885, 47.273, 68.027), rgba(234, 97, 29, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=freedom].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(226, 33, 86, 0.08), rgba(185, 26, 69, 0.06));
|
||||
}
|
||||
body[data-theme=freedom].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(226, 33, 86, 0.06), rgba(185, 26, 69, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=freedom].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(82.2051282051, 55.1948717949, 63.4153846154), rgba(226, 33, 86, 0.07));
|
||||
}
|
||||
body[data-theme=freedom].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(79.1538461538, 53.1461538462, 61.0615384615), rgba(226, 33, 86, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=cyber].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(124, 179, 66, 0.08), rgba(85, 139, 47, 0.06));
|
||||
}
|
||||
body[data-theme=cyber].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(124, 179, 66, 0.06), rgba(85, 139, 47, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=cyber].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(10.2, 10.2, 10.2), rgba(124, 179, 66, 0.07));
|
||||
}
|
||||
body[data-theme=cyber].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(7.65, 7.65, 7.65), rgba(124, 179, 66, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=mint].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(58, 183, 125, 0.08), rgba(39, 176, 101, 0.06));
|
||||
}
|
||||
body[data-theme=mint].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(58, 183, 125, 0.06), rgba(39, 176, 101, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=mint].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(38.6192771084, 64.7807228916, 53.5686746988), rgba(58, 183, 125, 0.07));
|
||||
}
|
||||
body[data-theme=mint].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(36.7144578313, 61.5855421687, 50.9265060241), rgba(58, 183, 125, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=autumn].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(183, 118, 58, 0.08), rgba(176, 121, 39, 0.06));
|
||||
}
|
||||
body[data-theme=autumn].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(183, 118, 58, 0.06), rgba(176, 121, 39, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=autumn].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(64.7807228916, 51.0771084337, 38.6192771084), rgba(183, 118, 58, 0.07));
|
||||
}
|
||||
body[data-theme=autumn].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(61.5855421687, 48.5578313253, 36.7144578313), rgba(183, 118, 58, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(255, 0, 255, 0.08), rgba(253, 163, 253, 0.06));
|
||||
}
|
||||
body[data-theme=flamingo].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(255, 0, 255, 0.06), rgba(253, 163, 253, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(66.176, 4.224, 66.176), rgba(255, 0, 255, 0.07));
|
||||
}
|
||||
body[data-theme=flamingo].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(61.382, 3.918, 61.382), rgba(255, 0, 255, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(73, 73, 73, 0.08), rgba(107, 107, 107, 0.06));
|
||||
}
|
||||
body[data-theme=monochrome].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(73, 73, 73, 0.06), rgba(107, 107, 107, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(10.2, 10.2, 10.2), rgba(73, 73, 73, 0.07));
|
||||
}
|
||||
body[data-theme=monochrome].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(7.65, 7.65, 7.65), rgba(73, 73, 73, 0.05));
|
||||
}
|
||||
|
||||
body[data-theme=salvador].card-gradient .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgba(25, 118, 210, 0.08), rgba(38, 166, 154, 0.06));
|
||||
}
|
||||
body[data-theme=salvador].card-gradient .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgba(25, 118, 210, 0.06), rgba(38, 166, 154, 0.04));
|
||||
}
|
||||
|
||||
body[data-theme=salvador].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
|
||||
.lnbits__dialog-card,
|
||||
.q-dialog-plugin--dark) {
|
||||
background-image: linear-gradient(135deg, rgb(43.9888888889, 64.2, 84.4111111111), rgba(25, 118, 210, 0.07));
|
||||
}
|
||||
body[data-theme=salvador].card-gradient.body--dark .q-drawer {
|
||||
background-image: linear-gradient(165deg, rgb(42.2416666667, 61.65, 81.0583333333), rgba(25, 118, 210, 0.05));
|
||||
}
|
||||
|
||||
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -630,12 +455,3 @@ video {
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
.error-code {
|
||||
font-size: clamp(15vh, 20vw, 30vh);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: clamp(1.5rem, 3vw, 3.75rem);
|
||||
font-weight: 300;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user