Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ff997ec1 | ||
|
|
eb55170905 | ||
|
|
dc466ae738 | ||
|
|
dd5fe8b7a2 | ||
|
|
5196ee6a15 | ||
|
|
3498cfb5b8 | ||
|
|
30ffe437a0 | ||
|
|
574641d432 | ||
|
|
90d14629d9 | ||
|
|
3be06269c2 | ||
|
|
d7723696d8 | ||
|
|
91f7b7730d | ||
|
|
b2aaf97cf3 |
+4
-75
@@ -6,21 +6,11 @@
|
||||
# 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
|
||||
DEBUG_DATABASE=False
|
||||
BUNDLE_ASSETS=True
|
||||
# add `?profiler=true` to the url to enable the profiler for that request
|
||||
PROFILER=False
|
||||
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
ENABLE_LOG_TO_FILE=true
|
||||
@@ -30,10 +20,6 @@ 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 ===
|
||||
|
||||
@@ -69,7 +55,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
|
||||
@@ -104,7 +90,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"
|
||||
@@ -119,8 +105,6 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# Toggle the background styling on burger menus / drawers
|
||||
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
######################################
|
||||
@@ -203,15 +187,6 @@ 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
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||
@@ -283,50 +258,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
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
@@ -382,12 +313,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
|
||||
|
||||
@@ -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
|
||||
+18
-10
@@ -1,17 +1,29 @@
|
||||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
test-frontend:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: test-frontend
|
||||
npm: true
|
||||
|
||||
test-api:
|
||||
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 +37,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 +51,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 +65,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,7 +81,7 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
@@ -89,11 +101,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,11 @@ 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,7 +38,8 @@ jobs:
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
cd docker/regtest
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest
|
||||
sudo chmod -R a+rwx .
|
||||
@@ -49,16 +50,16 @@ 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
|
||||
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -41,33 +40,19 @@ 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 ]
|
||||
|
||||
@@ -52,33 +52,19 @@ 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 ]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: tests
|
||||
name: tests pytest
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -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,11 +14,11 @@ 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 ]
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# AGENTS.md - AI Coding Agent Guide for LNbits
|
||||
|
||||
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
|
||||
|
||||
## Core Behavior
|
||||
|
||||
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
|
||||
- Prefer the simplest implementation that solves the request.
|
||||
- Make surgical changes. Every changed line should trace back to the task.
|
||||
- Do not refactor, reformat, rename, or clean adjacent code unless required.
|
||||
- Remove only dead code or imports created by your own changes.
|
||||
- Define success criteria for non-trivial work and verify them before reporting done.
|
||||
|
||||
## LNbits Architecture
|
||||
|
||||
- Keep core lean. Prefer/assess extensions for non-core features.
|
||||
- Preserve compatibility with existing extensions and wallet backends.
|
||||
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
|
||||
- Use existing CRUD, services, settings, and migration patterns.
|
||||
- Do not edit generated files, bundled vendor files, or unrelated extension code.
|
||||
|
||||
## Security-Sensitive Areas
|
||||
|
||||
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
|
||||
|
||||
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
|
||||
|
||||
## Commands and Verification
|
||||
|
||||
Read `Makefile` before running project commands.
|
||||
|
||||
Use Makefile targets instead of hand-written commands when available:
|
||||
|
||||
- `make check` for full checks.
|
||||
- `make test-unit` for unit tests.
|
||||
- `make test-api` for API tests.
|
||||
- `make test-wallets` for wallet tests.
|
||||
- `make checkbundle` when bundled frontend assets may be affected.
|
||||
- `make format` only when formatting is intended.
|
||||
|
||||
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
|
||||
|
||||
## Maintenance
|
||||
|
||||
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
|
||||
|
||||
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
|
||||
|
||||
## Reporting
|
||||
|
||||
When finished, report:
|
||||
|
||||
- Summary of what changed.
|
||||
- Files touched.
|
||||
- Makefile targets or checks run.
|
||||
- Anything not verified and why.
|
||||
+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='*'"]
|
||||
|
||||
@@ -14,13 +14,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 +26,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"]
|
||||
@@ -41,6 +41,10 @@ dev:
|
||||
docker:
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
test-frontend:
|
||||
npm install
|
||||
npm test
|
||||
|
||||
test-wallets:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
@@ -66,7 +70,6 @@ test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
rm -rf ./tests/data \
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
|
||||
@@ -1,12 +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 alt="lnbits_head" src="docs/assets/header.jpg" />
|
||||
 [![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" />
|
||||
[](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
# LNbits — The most powerful Bitcoin & Lightning toolkit
|
||||
@@ -46,7 +46,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,25 +54,25 @@ 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
|
||||
|
||||
|
||||
@@ -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 |
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<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)
|
||||
|
||||
# LNBits Admin UI
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<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)
|
||||
|
||||
# Backend Wallet Comparison Table
|
||||
|
||||
@@ -49,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. |
|
||||
@@ -57,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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ nav_order: 1
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
   [](https://extensions.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">
|
||||
|
||||
# Basic installation
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<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)
|
||||
|
||||
# LNbits Super User (SU)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<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)
|
||||
|
||||
# LNbits Roles: A Quick Overview
|
||||
|
||||
|
||||
+3
-30
@@ -14,6 +14,7 @@ nav_order: 3
|
||||

|
||||

|
||||
[<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)
|
||||
|
||||
# Backend wallets
|
||||
|
||||
@@ -40,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) | | |
|
||||
@@ -52,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**
|
||||
@@ -127,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
-10
@@ -65,7 +65,6 @@ from .middleware import (
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_profiler_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
@@ -197,9 +196,6 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
if settings.profiler:
|
||||
add_profiler_middleware(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -472,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
|
||||
|
||||
@@ -149,12 +148,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:
|
||||
@@ -292,7 +293,6 @@ async def create_payment(
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -304,16 +304,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,15 +313,12 @@ async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
) -> None:
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
)
|
||||
if new_checking_id and new_checking_id != payment.checking_id:
|
||||
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
|
||||
payment.checking_id = new_checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
@@ -347,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:
|
||||
@@ -401,6 +392,7 @@ async def get_payment_count_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
@@ -433,6 +425,7 @@ async def get_daily_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -482,6 +475,7 @@ async def get_wallets_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+31
-103
@@ -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 create_wallet, 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,13 +30,6 @@ 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:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("accounts", 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,79 +124,55 @@ 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 def get_user(user_id: str, 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)
|
||||
account = await get_account(user_id, conn=conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn=conn)
|
||||
@@ -242,7 +191,6 @@ async def get_user_from_account(
|
||||
|
||||
return User(
|
||||
id=account.id,
|
||||
activated=account.activated,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
@@ -256,7 +204,6 @@ 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 {},
|
||||
)
|
||||
|
||||
|
||||
@@ -268,31 +215,12 @@ async def update_user_access_control_list(
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -45,13 +45,17 @@ 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())
|
||||
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}")
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
@@ -65,7 +69,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 +78,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 +228,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,
|
||||
@@ -253,11 +252,8 @@ async def get_base_wallet_for_key(
|
||||
) -> 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
|
||||
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
|
||||
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
|
||||
""",
|
||||
{"key": key},
|
||||
BaseWallet,
|
||||
@@ -298,16 +294,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}")
|
||||
|
||||
+124
-91
@@ -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,13 +776,16 @@ 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):
|
||||
@@ -748,9 +804,11 @@ async def m039_index_payments(db: Connection):
|
||||
]
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_payments_{index}...")
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m040_index_wallets(db: Connection):
|
||||
@@ -767,9 +825,11 @@ async def m040_index_wallets(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m042_index_accounts(db: Connection):
|
||||
@@ -783,35 +843,8 @@ async def m042_index_accounts(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(f"""
|
||||
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")
|
||||
|
||||
|
||||
async def m045_add_external_id_to_payments(db: Connection):
|
||||
"""
|
||||
Adds external_id column to apipayments.
|
||||
Used for external payment references.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
|
||||
logger.debug("Creating index idx_payments_external_id...")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_external_id
|
||||
ON apipayments (external_id);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ from .payments import (
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -91,7 +90,6 @@ __all__ = [
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdatePaymentExtra",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
|
||||
@@ -12,7 +12,7 @@ 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,
|
||||
@@ -269,30 +269,10 @@ class ExtensionRelease(BaseModel):
|
||||
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.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
|
||||
|
||||
return extension_releases
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return []
|
||||
@@ -797,32 +777,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,17 +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.helpers import is_valid_external_id
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,11 +41,6 @@ class PaymentExtra(BaseModel):
|
||||
lnurl_response: str | None = None
|
||||
|
||||
|
||||
class UpdatePaymentExtra(BaseModel):
|
||||
payment_hash: str
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: str | None = None
|
||||
@@ -59,11 +60,6 @@ class CreatePayment(BaseModel):
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -88,11 +84,6 @@ class Payment(BaseModel):
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
@@ -139,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):
|
||||
@@ -167,7 +194,6 @@ class PaymentFilters(FilterModel):
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
__sort_fields__ = [
|
||||
@@ -178,13 +204,11 @@ class PaymentFilters(FilterModel):
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
checking_id: str | None
|
||||
external_id: str | None
|
||||
amount: int
|
||||
fee: int
|
||||
memo: str | None
|
||||
@@ -268,7 +292,6 @@ class CreateInvoice(BaseModel):
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
@@ -283,10 +306,6 @@ class CreateInvoice(BaseModel):
|
||||
raise ValueError("The provided unit is not supported")
|
||||
return v
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class PaymentsStatusCount(BaseModel):
|
||||
incoming: int = 0
|
||||
@@ -325,12 +344,3 @@ class CancelInvoice(BaseModel):
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
def _validate_external_id(external_id: str | None) -> str | None:
|
||||
if external_id and not is_valid_external_id(external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
return external_id
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,14 +181,12 @@ class AccountId(BaseModel):
|
||||
|
||||
|
||||
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))
|
||||
@@ -242,7 +240,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
|
||||
@@ -278,7 +275,6 @@ class AccountFilters(FilterModel):
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
activated: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
email: str | None = None
|
||||
@@ -292,7 +288,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 +313,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 +356,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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-152
@@ -1,59 +1,19 @@
|
||||
import base64
|
||||
import io
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import filetype
|
||||
from fastapi import UploadFile
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
from lnbits.core.crud.assets import create_asset, get_user_assets_count
|
||||
from lnbits.core.models.assets import Asset
|
||||
from lnbits.settings import settings
|
||||
|
||||
IMAGE_MIME_TYPE_ALIASES = {
|
||||
"heic": "image/heic",
|
||||
"heics": "image/heics",
|
||||
"heif": "image/heif",
|
||||
"image/jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"jpg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
PIL_IMAGE_FORMAT_MIME_TYPES = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
}
|
||||
INLINE_ASSET_MIME_TYPES = {
|
||||
"image/heic",
|
||||
"image/heics",
|
||||
"image/heif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
}
|
||||
ASSET_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": (
|
||||
"sandbox; default-src 'none'; script-src 'none'; "
|
||||
"object-src 'none'; base-uri 'none'"
|
||||
),
|
||||
}
|
||||
THUMBNAIL_FORMAT_MIME_TYPES = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
|
||||
|
||||
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
|
||||
if not file.content_type:
|
||||
raise ValueError("File must have a content type.")
|
||||
|
||||
content_type = normalize_asset_mime_type(file.content_type)
|
||||
filename = file.filename or "unnamed"
|
||||
|
||||
if content_type not in allowed_asset_mime_types():
|
||||
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):
|
||||
@@ -69,126 +29,28 @@ 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."
|
||||
)
|
||||
|
||||
stored_mime_type = detect_image_mime_type(contents)
|
||||
if stored_mime_type != content_type:
|
||||
logger.warning(
|
||||
"Image MIME type mismatch: declared={}, detected={}",
|
||||
content_type,
|
||||
stored_mime_type,
|
||||
)
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type. "
|
||||
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
|
||||
)
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
|
||||
thumb_buffer = thumbnail_from_bytes(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,
|
||||
user_id=user_id,
|
||||
mime_type=stored_mime_type,
|
||||
mime_type=file.content_type,
|
||||
is_public=is_public,
|
||||
name=filename,
|
||||
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 normalize_asset_mime_type(content_type: str) -> str:
|
||||
content_type = content_type.split(";", 1)[0].strip().lower()
|
||||
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
|
||||
|
||||
|
||||
def normalize_media_type(media_type: str) -> str:
|
||||
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
|
||||
|
||||
|
||||
def thumbnail_media_type() -> str:
|
||||
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
|
||||
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
safe_filename = filename or "unnamed"
|
||||
quoted_filename = quote(safe_filename, safe="")
|
||||
if quoted_filename == safe_filename:
|
||||
return f'{disposition}; filename="{safe_filename}"'
|
||||
return f"{disposition}; filename*=utf-8''{quoted_filename}"
|
||||
|
||||
|
||||
def allowed_asset_mime_types() -> set[str]:
|
||||
return {
|
||||
mime_type
|
||||
for mime_type in (
|
||||
normalize_asset_mime_type(mime_type)
|
||||
for mime_type in settings.lnbits_assets_allowed_mime_types
|
||||
)
|
||||
if mime_type.startswith("image/")
|
||||
}
|
||||
|
||||
|
||||
def detect_image_mime_type(contents: bytes) -> str:
|
||||
kind = filetype.guess(contents)
|
||||
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
|
||||
|
||||
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
|
||||
verify_pil_image(contents, mime_type)
|
||||
return mime_type
|
||||
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
return mime_type
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if not mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
return mime_type
|
||||
|
||||
|
||||
def verify_pil_image(contents: bytes, mime_type: str) -> None:
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if detected_mime_type != mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
|
||||
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 (
|
||||
@@ -39,8 +38,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 +63,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -13,12 +12,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
|
||||
|
||||
|
||||
@@ -36,40 +29,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,
|
||||
@@ -119,8 +78,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
||||
"""
|
||||
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.")
|
||||
logger.warning("PayPal webhook ID not set; skipping verification.")
|
||||
return
|
||||
|
||||
required_headers = {
|
||||
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
|
||||
@@ -130,8 +89,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
||||
"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.")
|
||||
logger.warning("Missing PayPal webhook headers; skipping verification.")
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
|
||||
@@ -170,82 +129,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
||||
raise ValueError("PayPal webhook cannot be verified.") from exc
|
||||
|
||||
|
||||
def check_square_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
notification_url: str | None,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Square signature header is missing.")
|
||||
raise ValueError("Square signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Square webhook signature key is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
if not notification_url:
|
||||
logger.warning("Square webhook notification URL is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
signed_payload = notification_url.encode() + payload
|
||||
computed_signature = b64encode(
|
||||
hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
if hmac.compare_digest(computed_signature, sig_header) is not True:
|
||||
logger.warning("Square signature verification failed.")
|
||||
raise ValueError("Square signature verification failed.")
|
||||
|
||||
|
||||
def check_revolut_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
timestamp_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Revolut signature header is missing.")
|
||||
raise ValueError("Revolut signature header is missing.")
|
||||
|
||||
if not timestamp_header:
|
||||
logger.warning("Revolut timestamp header is missing.")
|
||||
raise ValueError("Revolut timestamp header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Revolut webhook signing secret is not set.")
|
||||
raise ValueError("Revolut webhook cannot be verified.")
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp_header)
|
||||
except ValueError as exc:
|
||||
logger.warning("Invalid Revolut timestamp.")
|
||||
raise ValueError("Invalid Revolut timestamp.") from exc
|
||||
|
||||
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
|
||||
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
|
||||
digest = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
expected_signature = f"v1={digest}"
|
||||
|
||||
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
|
||||
if not any(
|
||||
hmac.compare_digest(expected_signature, provided)
|
||||
for provided in provided_signatures
|
||||
):
|
||||
logger.warning("Revolut signature verification failed.")
|
||||
raise ValueError("Revolut signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
|
||||
@@ -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,7 +47,6 @@ from ..models import (
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .notifications import send_payment_notification_in_background
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
@@ -64,7 +62,6 @@ async def pay_invoice(
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
@@ -98,7 +95,6 @@ async def pay_invoice(
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
@@ -171,15 +167,15 @@ async def create_fiat_invoice(
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# TODO: move to payment
|
||||
# todo: move to payent
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
internal_payment = await update_payment(
|
||||
internal_payment, new_checking_id, conn=conn
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
return internal_payment
|
||||
|
||||
|
||||
@@ -219,7 +215,6 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
external_id=data.external_id,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
@@ -261,7 +256,6 @@ async def create_invoice(
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -346,7 +340,6 @@ async def create_invoice(
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -371,10 +364,10 @@ async def update_pending_payments(wallet_id: str):
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
status = await check_payment_status(payment)
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
@@ -514,8 +507,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)
|
||||
|
||||
|
||||
@@ -627,29 +618,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(
|
||||
@@ -783,7 +752,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)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -826,8 +795,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)
|
||||
)
|
||||
@@ -858,8 +825,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)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
@@ -876,7 +842,7 @@ async def update_payment_success_status(
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -902,7 +868,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"
|
||||
@@ -1067,41 +1033,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
|
||||
payment = await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
return payment
|
||||
|
||||
@@ -3,10 +3,8 @@ 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,
|
||||
@@ -23,7 +21,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,
|
||||
@@ -56,8 +53,6 @@ async def create_user_account_no_ckeck(
|
||||
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(
|
||||
@@ -98,15 +93,6 @@ async def create_user_account_no_ckeck(
|
||||
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 +171,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 +192,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.")
|
||||
|
||||
@@ -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 %}
|
||||
@@ -16,14 +16,7 @@ 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.services.assets import (
|
||||
ASSET_SECURITY_HEADERS,
|
||||
INLINE_ASSET_MIME_TYPES,
|
||||
content_disposition,
|
||||
create_user_asset,
|
||||
normalize_media_type,
|
||||
thumbnail_media_type,
|
||||
)
|
||||
from lnbits.core.services.assets import create_user_asset
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
@@ -64,11 +57,11 @@ async def api_get_asset(
|
||||
|
||||
|
||||
@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:
|
||||
@@ -82,7 +75,11 @@ async def api_get_asset_data(
|
||||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return asset_response(asset.data, asset.mime_type, asset.name)
|
||||
return Response(
|
||||
content=asset.data,
|
||||
media_type=asset.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return asset_response(
|
||||
return Response(
|
||||
content=(
|
||||
base64.b64decode(asset_info.thumbnail_base64)
|
||||
if asset_info.thumbnail_base64
|
||||
else b""
|
||||
),
|
||||
media_type=thumbnail_media_type(),
|
||||
filename=asset_info.name,
|
||||
media_type=asset_info.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@@ -175,16 +172,3 @@ async def api_delete_asset(
|
||||
|
||||
await delete_user_asset(account_id.id, asset_id)
|
||||
return SimpleStatus(success=True, message="Asset deleted successfully.")
|
||||
|
||||
|
||||
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
|
||||
media_type = normalize_media_type(media_type)
|
||||
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
**ASSET_SECURITY_HEADERS,
|
||||
"Content-Disposition": content_disposition(disposition, filename),
|
||||
},
|
||||
)
|
||||
|
||||
+16
-172
@@ -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,11 @@ 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.core.services.users import update_user_account
|
||||
from lnbits.decorators import (
|
||||
access_token_payload,
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
@@ -91,7 +84,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)
|
||||
|
||||
|
||||
@@ -100,7 +92,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,
|
||||
@@ -108,8 +100,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)
|
||||
|
||||
|
||||
@@ -130,79 +120,6 @@ 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,
|
||||
@@ -321,10 +238,7 @@ async def api_delete_user_api_token(
|
||||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request,
|
||||
provider: str,
|
||||
user_id: str | None,
|
||||
auth_user_id: str | None = Depends(optional_user_id),
|
||||
request: Request, provider: str, user_id: str | None = None
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
@@ -332,8 +246,6 @@ async def login_with_sso_provider(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
if user_id and user_id != auth_user_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
|
||||
|
||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||
with provider_sso:
|
||||
@@ -354,11 +266,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
userinfo = await provider_sso.verify_and_process(request)
|
||||
if not userinfo:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
|
||||
if provider_sso.state is None or provider_sso.state == "null":
|
||||
user_id = None
|
||||
else:
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
|
||||
@@ -390,14 +298,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,
|
||||
@@ -502,7 +408,7 @@ 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)
|
||||
) -> User | None:
|
||||
@@ -518,54 +424,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)
|
||||
|
||||
|
||||
@@ -575,7 +446,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:
|
||||
@@ -594,7 +465,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(
|
||||
@@ -609,20 +480,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
|
||||
@@ -639,28 +499,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
|
||||
|
||||
@@ -680,10 +527,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
|
||||
|
||||
@@ -4,30 +4,16 @@ from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentFilters
|
||||
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_revolut_signature,
|
||||
check_square_signature,
|
||||
check_stripe_signature,
|
||||
verify_paypal_webhook,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
create_fiat_invoice,
|
||||
create_wallet_invoice,
|
||||
service_fee_fiat,
|
||||
)
|
||||
from lnbits.db import Filter, Filters
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.core.services.payments import create_fiat_invoice
|
||||
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
|
||||
from lnbits.fiat.revolut import RevolutWallet
|
||||
from lnbits.fiat.square import SquareWallet
|
||||
from lnbits.settings import settings
|
||||
|
||||
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
||||
@@ -37,7 +23,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")
|
||||
@@ -63,41 +49,6 @@ async def api_generic_webhook_handler(
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "square":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("x-square-hmacsha256-signature")
|
||||
check_square_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
settings.square_webhook_signature_key,
|
||||
settings.square_payment_webhook_url,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_square_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "revolut":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("Revolut-Signature")
|
||||
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
|
||||
check_revolut_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
timestamp_header,
|
||||
settings.revolut_webhook_signing_secret,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_revolut_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}'.",
|
||||
@@ -136,7 +87,7 @@ async def _handle_stripe_intent_session_completed(event: dict):
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await check_fiat_status(payment)
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
async def _handle_stripe_checkout_session_completed(event: dict):
|
||||
@@ -159,7 +110,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):
|
||||
@@ -204,7 +155,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(
|
||||
@@ -229,10 +180,8 @@ async def _get_stripe_subscription_payment_options(
|
||||
|
||||
|
||||
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)
|
||||
@@ -243,20 +192,23 @@ async def handle_paypal_event(event: dict):
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await check_fiat_status(payment)
|
||||
await payment.check_fiat_status()
|
||||
return
|
||||
|
||||
if event_type in ("PAYMENT.SALE.COMPLETED"):
|
||||
if event_type in (
|
||||
"PAYMENT.SALE.COMPLETED",
|
||||
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
|
||||
):
|
||||
await _handle_paypal_subscription_payment(resource)
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled PayPal event type: '{event_type}'.")
|
||||
logger.info(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")
|
||||
currency = (amount_info.get("currency_code") or "").upper()
|
||||
total = amount_info.get("value")
|
||||
if not currency or total is None:
|
||||
raise ValueError("PayPal subscription event missing amount.")
|
||||
|
||||
@@ -264,14 +216,18 @@ async def _handle_paypal_subscription_payment(resource: dict):
|
||||
if not custom_id:
|
||||
raise ValueError("PayPal subscription event missing custom metadata.")
|
||||
|
||||
payment_options = _deserialize_paypal_metadata(custom_id)
|
||||
try:
|
||||
metadata = json.loads(custom_id)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
payment_options = FiatSubscriptionPaymentOptions(**metadata)
|
||||
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": {
|
||||
@@ -291,7 +247,7 @@ async def _handle_paypal_subscription_payment(resource: dict):
|
||||
),
|
||||
)
|
||||
|
||||
await check_fiat_status(payment)
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
def _paypal_extract_payment_hash(resource: dict) -> str | None:
|
||||
@@ -302,408 +258,3 @@ def _paypal_extract_payment_hash(resource: dict) -> str | None:
|
||||
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()
|
||||
|
||||
|
||||
async def handle_square_event(event: dict):
|
||||
event_id = event.get("event_id") or event.get("id", "")
|
||||
event_type = event.get("type", "")
|
||||
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
|
||||
|
||||
if event_type == "payment.updated":
|
||||
await _handle_square_payment_event(event)
|
||||
return
|
||||
|
||||
if event_type == "invoice.payment_made":
|
||||
await _handle_square_invoice_payment_made(event)
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Square event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def handle_revolut_event(event: dict):
|
||||
event_type = event.get("event", "")
|
||||
order_id = event.get("order_id")
|
||||
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
|
||||
|
||||
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
|
||||
if not order_id:
|
||||
logger.warning("Revolut event missing order_id.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if payment:
|
||||
await check_fiat_status(payment)
|
||||
return
|
||||
|
||||
if event_type == "ORDER_COMPLETED":
|
||||
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
|
||||
await _handle_revolut_subscription_order_paid(order_id)
|
||||
return
|
||||
|
||||
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
if event_type == "SUBSCRIPTION_INITIATED":
|
||||
logger.info("Revolut subscription initiated event received.")
|
||||
return
|
||||
|
||||
if event_type in [
|
||||
"SUBSCRIPTION_CANCELLED",
|
||||
"SUBSCRIPTION_FINISHED",
|
||||
"SUBSCRIPTION_OVERDUE",
|
||||
]:
|
||||
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def _get_revolut_provider() -> RevolutWallet | None:
|
||||
fiat_provider = await get_fiat_provider("revolut")
|
||||
if not isinstance(fiat_provider, RevolutWallet):
|
||||
logger.warning("Revolut fiat provider is not configured.")
|
||||
return None
|
||||
return fiat_provider
|
||||
|
||||
|
||||
async def _handle_revolut_subscription(
|
||||
subscription: dict,
|
||||
fiat_provider: RevolutWallet,
|
||||
order_id: str | None = None,
|
||||
order: dict | None = None,
|
||||
):
|
||||
subscription_id = subscription.get("id")
|
||||
if not subscription_id:
|
||||
logger.warning("Revolut subscription missing id.")
|
||||
return
|
||||
|
||||
reference = fiat_provider.deserialize_subscription_reference(
|
||||
subscription.get("external_reference")
|
||||
)
|
||||
if not reference:
|
||||
logger.warning("Revolut subscription event missing LNbits metadata.")
|
||||
return
|
||||
|
||||
if not order_id:
|
||||
cycle_id = subscription.get("current_cycle_id")
|
||||
if not cycle_id:
|
||||
logger.warning("Revolut subscription missing current_cycle_id.")
|
||||
return
|
||||
|
||||
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
|
||||
order_id = cycle.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Revolut subscription cycle missing order_id.")
|
||||
return
|
||||
|
||||
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if existing_payment:
|
||||
if existing_payment.external_id != subscription_id:
|
||||
existing_payment.external_id = subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
if not order:
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
amount_minor = order.get("amount")
|
||||
currency = (order.get("currency") or "").upper()
|
||||
if amount_minor is None or not currency:
|
||||
raise ValueError("Revolut subscription order missing amount or currency.")
|
||||
|
||||
extra = {
|
||||
**(reference.extra or {}),
|
||||
"subscription_request_id": reference.subscription_request_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": reference.tag,
|
||||
"subscription": {
|
||||
"checking_id": f"order_{order_id}",
|
||||
"payment_request": order.get("checkout_url") or "",
|
||||
},
|
||||
}
|
||||
lnbits_payment = await _create_revolut_subscription_payment(
|
||||
wallet_id=reference.wallet_id,
|
||||
amount_minor=amount_minor,
|
||||
currency=currency,
|
||||
memo=reference.memo or "",
|
||||
extra=extra,
|
||||
order_id=order_id,
|
||||
payment_request=order.get("checkout_url") or "",
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_revolut_subscription_order_paid(order_id: str):
|
||||
fiat_provider = await _get_revolut_provider()
|
||||
if not fiat_provider:
|
||||
return
|
||||
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
order_type = (order.get("type") or "").lower()
|
||||
order_state = (order.get("state") or "").upper()
|
||||
if order_type != "payment" or order_state != "COMPLETED":
|
||||
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
channel_data = order.get("channel_data") or {}
|
||||
subscription_id = channel_data.get("subscription_id")
|
||||
if not subscription_id:
|
||||
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
|
||||
return
|
||||
|
||||
subscription = await fiat_provider.get_subscription(subscription_id)
|
||||
if subscription.get("state") != "active":
|
||||
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
|
||||
return
|
||||
|
||||
await _handle_revolut_subscription(
|
||||
subscription, fiat_provider, order_id=order_id, order=order
|
||||
)
|
||||
|
||||
|
||||
async def _create_revolut_subscription_payment(
|
||||
wallet_id: str,
|
||||
amount_minor: int,
|
||||
currency: str,
|
||||
memo: str,
|
||||
extra: dict,
|
||||
order_id: str,
|
||||
payment_request: str,
|
||||
subscription_id: str,
|
||||
) -> Payment:
|
||||
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
|
||||
payment = await create_wallet_invoice(
|
||||
wallet_id,
|
||||
CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
internal=True,
|
||||
external_id=subscription_id,
|
||||
),
|
||||
)
|
||||
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
|
||||
payment.fiat_provider = "revolut"
|
||||
payment.extra["fiat_checking_id"] = f"order_{order_id}"
|
||||
payment.extra["fiat_payment_request"] = payment_request
|
||||
checking_id = f"fiat_revolut_order_{order_id}"
|
||||
await update_payment(payment, checking_id)
|
||||
payment.checking_id = checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def _handle_square_payment_event(event: dict):
|
||||
payment = _square_extract_payment(event)
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if payment_options.wallet_id:
|
||||
if not _square_payment_is_completed(payment):
|
||||
logger.debug("Square subscription payment is not completed yet.")
|
||||
return
|
||||
await _handle_square_subscription_payment(payment, payment_options)
|
||||
return
|
||||
|
||||
order_id = payment.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square payment event missing order_id.")
|
||||
return
|
||||
|
||||
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
|
||||
if not lnbits_payment:
|
||||
logger.warning(f"No payment found for Square order: '{order_id}'.")
|
||||
return
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_square_invoice_payment_made(event: dict):
|
||||
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
|
||||
order_id = invoice.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square invoice.payment_made event missing order_id.")
|
||||
return
|
||||
subscription_id = invoice.get("subscription_id")
|
||||
|
||||
fiat_provider = await get_fiat_provider("square")
|
||||
if not isinstance(fiat_provider, SquareWallet):
|
||||
logger.warning("Square fiat provider is not configured.")
|
||||
return
|
||||
|
||||
payment = await fiat_provider.get_payment_for_order(order_id)
|
||||
if not payment:
|
||||
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
|
||||
return
|
||||
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if not payment_options.wallet_id:
|
||||
payment_id = payment.get("id")
|
||||
stored_payment = (
|
||||
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
|
||||
if payment_id
|
||||
else None
|
||||
)
|
||||
if not stored_payment and subscription_id:
|
||||
stored_payments = await get_payments(
|
||||
filters=Filters(
|
||||
filters=[
|
||||
Filter.parse_query(
|
||||
"external_id", [subscription_id], PaymentFilters
|
||||
)
|
||||
],
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
stored_payment = stored_payments[0] if stored_payments else None
|
||||
if stored_payment:
|
||||
payment_options = _square_payment_options_from_payment(stored_payment)
|
||||
else:
|
||||
logger.warning("Square subscription payment missing LNbits metadata.")
|
||||
return
|
||||
|
||||
await _handle_square_subscription_payment(
|
||||
payment,
|
||||
payment_options,
|
||||
invoice.get("public_url") or "",
|
||||
square_subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_square_subscription_payment(
|
||||
payment: dict,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
payment_request: str = "",
|
||||
square_subscription_id: str | None = None,
|
||||
):
|
||||
amount_money = payment.get("amount_money") or {}
|
||||
amount = amount_money.get("amount")
|
||||
currency = (amount_money.get("currency") or "").upper()
|
||||
payment_id = payment.get("id")
|
||||
if amount is None or not currency or not payment_id:
|
||||
raise ValueError("Square subscription payment event missing payment amount.")
|
||||
wallet_id = payment_options.wallet_id
|
||||
if not wallet_id:
|
||||
raise ValueError("Square subscription payment event missing wallet_id.")
|
||||
|
||||
checking_id = f"payment_{payment_id}"
|
||||
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
|
||||
if existing_payment:
|
||||
if (
|
||||
square_subscription_id
|
||||
and existing_payment.external_id != square_subscription_id
|
||||
):
|
||||
existing_payment.external_id = square_subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
square_subscription_id = square_subscription_id or (
|
||||
payment_options.extra or {}
|
||||
).get("square_subscription_id")
|
||||
extra = {
|
||||
**(payment_options.extra or {}),
|
||||
"subscription_request_id": payment_options.subscription_request_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": payment_options.tag,
|
||||
"subscription": {
|
||||
"checking_id": checking_id,
|
||||
"payment_request": payment_request,
|
||||
},
|
||||
}
|
||||
|
||||
lnbits_payment = await create_fiat_invoice(
|
||||
wallet_id=wallet_id,
|
||||
invoice_data=CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount / 100,
|
||||
memo=payment_options.memo or "",
|
||||
extra=extra,
|
||||
fiat_provider="square",
|
||||
external_id=square_subscription_id,
|
||||
),
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
def _square_payment_options_from_payment(
|
||||
payment: Payment,
|
||||
) -> FiatSubscriptionPaymentOptions:
|
||||
extra = payment.extra or {}
|
||||
return FiatSubscriptionPaymentOptions(
|
||||
wallet_id=payment.wallet_id,
|
||||
tag=extra.get("tag") or payment.tag,
|
||||
subscription_request_id=extra.get("subscription_request_id"),
|
||||
extra=extra,
|
||||
memo=payment.memo,
|
||||
)
|
||||
|
||||
|
||||
def _square_extract_payment(event: dict) -> dict:
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
return event_object.get("payment") or event_object
|
||||
|
||||
|
||||
def _square_payment_is_completed(payment: dict) -> bool:
|
||||
return (payment.get("status") or "").upper() == "COMPLETED"
|
||||
|
||||
|
||||
def _square_payment_note(payment: dict) -> str:
|
||||
return payment.get("note") or payment.get("payment_note") or ""
|
||||
|
||||
|
||||
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
|
||||
try:
|
||||
meta = json.loads(custom_id)
|
||||
if not isinstance(meta, list):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
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, TypeError):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
|
||||
@@ -2,10 +2,8 @@ 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
|
||||
@@ -16,14 +14,10 @@ from lnbits.core.models import (
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
@@ -40,7 +34,6 @@ 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,
|
||||
@@ -98,16 +91,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
|
||||
|
||||
@@ -600,49 +589,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)
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.wallets import WalletTypeInfo
|
||||
from lnbits.core.services import update_cached_settings
|
||||
from lnbits.core.services.fiat_providers import test_connection
|
||||
from lnbits.decorators import check_admin, require_admin_key
|
||||
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
|
||||
|
||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||
|
||||
|
||||
class RevolutCreateWebhook(BaseModel):
|
||||
url: str
|
||||
endpoint: str | None = None
|
||||
api_secret_key: str | None = None
|
||||
api_version: str | None = None
|
||||
|
||||
|
||||
class RevolutCreateWebhookResponse(BaseModel):
|
||||
id: str | None = None
|
||||
url: str
|
||||
events: list[str] = []
|
||||
signing_secret: str
|
||||
already_exists: bool = False
|
||||
|
||||
|
||||
@fiat_router.put(
|
||||
"/check/{provider}",
|
||||
status_code=HTTPStatus.OK,
|
||||
@@ -40,54 +21,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||
return await test_connection(provider)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/revolut/webhook",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_create_revolut_webhook(
|
||||
data: RevolutCreateWebhook,
|
||||
) -> RevolutCreateWebhookResponse:
|
||||
try:
|
||||
webhook = await RevolutWallet.create_webhook(
|
||||
url=data.url,
|
||||
endpoint=data.endpoint,
|
||||
api_secret_key=data.api_secret_key,
|
||||
api_version=data.api_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Revolut webhook."
|
||||
) from exc
|
||||
|
||||
signing_secret = webhook.get("signing_secret")
|
||||
webhook_url = webhook.get("url") or data.url
|
||||
if not signing_secret:
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Revolut returned no webhook signing secret."
|
||||
)
|
||||
|
||||
updated_settings = {
|
||||
"revolut_payment_webhook_url": webhook_url,
|
||||
"revolut_webhook_signing_secret": signing_secret,
|
||||
}
|
||||
for key, value in updated_settings.items():
|
||||
await set_settings_field(key, value)
|
||||
update_cached_settings(updated_settings)
|
||||
|
||||
return RevolutCreateWebhookResponse(
|
||||
id=webhook.get("id"),
|
||||
url=webhook_url,
|
||||
events=webhook.get("events") or [],
|
||||
signing_secret=signing_secret,
|
||||
already_exists=webhook.get("already_exists", False),
|
||||
)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/{provider}/subscription",
|
||||
status_code=HTTPStatus.OK,
|
||||
@@ -97,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")
|
||||
@@ -119,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
|
||||
|
||||
|
||||
|
||||
@@ -42,38 +42,54 @@ async def robots():
|
||||
|
||||
|
||||
@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",
|
||||
{
|
||||
"status_code": 404,
|
||||
"message": f"Extension {ext_id} not found, refresh Preview.",
|
||||
},
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
response = template_renderer().TemplateResponse(
|
||||
request,
|
||||
html_file_path.as_posix(),
|
||||
{
|
||||
"user": user.json(),
|
||||
},
|
||||
)
|
||||
|
||||
response = FileResponse(file_full_path.absolute().as_posix())
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
@@ -192,15 +208,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 +224,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.html", {"public": True})
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,7 +34,6 @@ from lnbits.core.models import (
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
SimpleStatus,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
@@ -264,7 +263,6 @@ async def api_payments_create(
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
labels=invoice_data.labels,
|
||||
external_id=invoice_data.external_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -298,38 +296,6 @@ async def api_update_payment_labels(
|
||||
return SimpleStatus(success=True, message="Payment labels updated.")
|
||||
|
||||
|
||||
@payment_router.patch(
|
||||
"/extra",
|
||||
name="Update payment extra",
|
||||
description="Append new extra metadata to a payment.",
|
||||
response_model=Payment,
|
||||
)
|
||||
async def api_update_payment_extra(
|
||||
data: UpdatePaymentExtra,
|
||||
key_type: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Payment:
|
||||
payment = await get_standalone_payment(
|
||||
data.payment_hash, wallet_id=key_type.wallet.id
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
|
||||
if not payment.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
|
||||
)
|
||||
|
||||
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
|
||||
if duplicate_keys:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
|
||||
)
|
||||
|
||||
payment.extra.update(data.extra)
|
||||
await update_payment(payment)
|
||||
return payment
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
|
||||
@@ -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
|
||||
@@ -159,8 +157,11 @@ async def api_users_delete_user(
|
||||
user_id: str, account: Account = 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(
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastapi import (
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_wallets_paginated,
|
||||
)
|
||||
@@ -38,6 +37,7 @@ from lnbits.decorators import (
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..crud import (
|
||||
delete_wallet,
|
||||
@@ -134,7 +134,9 @@ async def api_reset_wallet_keys(
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
clear_wallet_cache(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}")
|
||||
|
||||
wallet.adminkey = uuid4().hex
|
||||
wallet.inkey = uuid4().hex
|
||||
|
||||
+39
-19
@@ -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
|
||||
|
||||
@@ -35,7 +36,7 @@ if settings.lnbits_database_url:
|
||||
else:
|
||||
if not database_uri.startswith("postgres://"):
|
||||
raise ValueError(
|
||||
"Please use the 'postgres://...' format for the database URL."
|
||||
"Please use the 'postgres://...' " "format for the database URL."
|
||||
)
|
||||
DB_TYPE = POSTGRES
|
||||
|
||||
@@ -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>"
|
||||
@@ -317,7 +326,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
|
||||
@@ -560,9 +592,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def pagination(self) -> str:
|
||||
stmt = ""
|
||||
if self.limit == 0:
|
||||
self.limit = 1000
|
||||
self.limit = 10 if self.limit is None else self.limit
|
||||
self.limit = self.limit or 10
|
||||
stmt += f"LIMIT {min(1000, self.limit)} "
|
||||
if self.offset:
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
@@ -615,12 +645,6 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
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):
|
||||
@@ -638,7 +662,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)
|
||||
@@ -656,8 +680,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
|
||||
|
||||
@@ -675,12 +700,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)
|
||||
@@ -736,7 +756,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)
|
||||
|
||||
@@ -261,7 +261,6 @@ async def check_account_id_exists(
|
||||
account_id,
|
||||
expiry=settings.auth_authentication_cache_minutes * 60,
|
||||
)
|
||||
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
|
||||
|
||||
return account_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)
|
||||
|
||||
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .paypal import PayPalWallet
|
||||
from .revolut import RevolutWallet
|
||||
from .square import SquareWallet
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
paypal = "PayPalWallet"
|
||||
square = "SquareWallet"
|
||||
revolut = "RevolutWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider | None:
|
||||
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
__all__ = [
|
||||
"PayPalWallet",
|
||||
"RevolutWallet",
|
||||
"SquareWallet",
|
||||
"StripeWallet",
|
||||
]
|
||||
|
||||
+3
-7
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
|
||||
description="Unique ID that can be used to identify the subscription request."
|
||||
"If not provided, one will be generated.",
|
||||
)
|
||||
customer_email: str | None = Field(
|
||||
default=None,
|
||||
description="The customer email to use for the subscription.",
|
||||
)
|
||||
tag: str | None = Field(
|
||||
default=None,
|
||||
description="Payments created by the recurring subscription"
|
||||
@@ -131,15 +127,15 @@ class FiatSubscriptionResponse(BaseModel):
|
||||
|
||||
|
||||
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||
paid = True # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = True
|
||||
|
||||
|
||||
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||
paid = False # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = False
|
||||
|
||||
|
||||
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||
paid = None # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = None
|
||||
|
||||
|
||||
class FiatProvider(ABC):
|
||||
|
||||
+18
-63
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -24,8 +24,6 @@ from .base import (
|
||||
FiatSubscriptionResponse,
|
||||
)
|
||||
|
||||
FiatMethod = Literal["checkout", "subscription"]
|
||||
|
||||
|
||||
class PayPalCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
@@ -36,21 +34,11 @@ class PayPalCheckoutOptions(BaseModel):
|
||||
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):
|
||||
@@ -108,14 +96,6 @@ class PayPalWallet(FiatProvider):
|
||||
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()
|
||||
@@ -191,7 +171,6 @@ class PayPalWallet(FiatProvider):
|
||||
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 {}
|
||||
@@ -203,6 +182,7 @@ class PayPalWallet(FiatProvider):
|
||||
await self._ensure_access_token()
|
||||
payload = {
|
||||
"plan_id": subscription_id,
|
||||
"quantity": str(quantity),
|
||||
"custom_id": self._serialize_metadata(payment_options),
|
||||
"application_context": {
|
||||
"return_url": success_url,
|
||||
@@ -225,7 +205,7 @@ class PayPalWallet(FiatProvider):
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=approval_url,
|
||||
subscription_request_id=data.get("id"),
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
@@ -239,10 +219,6 @@ class PayPalWallet(FiatProvider):
|
||||
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(
|
||||
@@ -264,20 +240,12 @@ class PayPalWallet(FiatProvider):
|
||||
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())
|
||||
order_id = self._normalize_paypal_id(checking_id)
|
||||
r = await self.client.get(
|
||||
f"/v2/checkout/orders/{order_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()
|
||||
@@ -302,14 +270,6 @@ class PayPalWallet(FiatProvider):
|
||||
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)
|
||||
@@ -320,20 +280,15 @@ class PayPalWallet(FiatProvider):
|
||||
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)
|
||||
md = {
|
||||
"wallet_id": payment_options.wallet_id,
|
||||
"memo": payment_options.memo,
|
||||
"extra": payment_options.extra,
|
||||
"tag": payment_options.tag,
|
||||
"subscription_request_id": payment_options.subscription_request_id,
|
||||
}
|
||||
raw = json.dumps(md)
|
||||
return raw[:127] # PayPal custom_id limit
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class RevolutCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class RevolutCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checkout: RevolutCheckoutOptions | None = None
|
||||
|
||||
|
||||
class RevolutSubscriptionReference(BaseModel):
|
||||
wallet_id: str
|
||||
tag: str | None = None
|
||||
subscription_request_id: str | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
REVOLUT_WEBHOOK_EVENTS = [
|
||||
"ORDER_AUTHORISED",
|
||||
"ORDER_COMPLETED",
|
||||
"SUBSCRIPTION_INITIATED",
|
||||
]
|
||||
|
||||
ZERO_DECIMAL_CURRENCIES = {
|
||||
"BIF",
|
||||
"CLP",
|
||||
"DJF",
|
||||
"GNF",
|
||||
"ISK",
|
||||
"JPY",
|
||||
"KMF",
|
||||
"KRW",
|
||||
"PYG",
|
||||
"RWF",
|
||||
"UGX",
|
||||
"VND",
|
||||
"VUV",
|
||||
"XAF",
|
||||
"XOF",
|
||||
"XPF",
|
||||
}
|
||||
THREE_DECIMAL_CURRENCIES = {
|
||||
"BHD",
|
||||
"IQD",
|
||||
"JOD",
|
||||
"KWD",
|
||||
"LYD",
|
||||
"OMR",
|
||||
"TND",
|
||||
}
|
||||
REVOLUT_CUSTOMER_LIST_LIMIT = 500
|
||||
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
|
||||
REVOLUT_REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
class RevolutWallet(FiatProvider):
|
||||
"""https://developer.revolut.com/docs/merchant"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing RevolutWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.revolut_api_endpoint:
|
||||
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
|
||||
if not settings.revolut_api_secret_key:
|
||||
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
|
||||
"Revolut-Api-Version": settings.revolut_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("RevolutWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing Revolut 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:
|
||||
r = await self.client.get(
|
||||
"/api/orders",
|
||||
params={"limit": 1},
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
_ = r.json()
|
||||
return FiatStatusResponse(balance=0)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 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 Revolut options"
|
||||
)
|
||||
|
||||
amount_minor = self.amount_to_minor_units(amount, currency)
|
||||
checkout = opts.checkout or RevolutCheckoutOptions()
|
||||
success_url = (
|
||||
checkout.success_url
|
||||
or settings.revolut_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"amount": amount_minor,
|
||||
"currency": currency.upper(),
|
||||
"description": checkout.description or memo or "LNbits Invoice",
|
||||
"redirect_url": success_url,
|
||||
"metadata": {
|
||||
**checkout.metadata,
|
||||
"payment_hash": payment_hash,
|
||||
"alan_action": "invoice",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
order_id = data.get("id")
|
||||
checkout_url = data.get("checkout_url")
|
||||
if not order_id or not checkout_url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing order id or url"
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"order_{order_id}",
|
||||
payment_request=checkout_url,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
quantity: int,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
if quantity != 1:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False,
|
||||
error_message="Revolut subscriptions do not support quantity.",
|
||||
)
|
||||
|
||||
wallet_id = payment_options.wallet_id
|
||||
if not wallet_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Wallet ID is required."
|
||||
)
|
||||
|
||||
extra = payment_options.extra or {}
|
||||
if not payment_options.subscription_request_id:
|
||||
payment_options.subscription_request_id = urlsafe_short_hash()
|
||||
|
||||
reference = RevolutSubscriptionReference(
|
||||
wallet_id=wallet_id,
|
||||
tag=payment_options.tag,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
extra=extra,
|
||||
memo=payment_options.memo,
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"plan_variation_id": subscription_id,
|
||||
"external_reference": self._serialize_subscription_reference(reference),
|
||||
"setup_order_redirect_url": (
|
||||
payment_options.success_url
|
||||
or settings.revolut_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
),
|
||||
}
|
||||
if extra.get("trial_duration"):
|
||||
payload["trial_duration"] = extra["trial_duration"]
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
"Idempotency-Key": payment_options.subscription_request_id,
|
||||
}
|
||||
|
||||
try:
|
||||
customer_id, customer_error = await self._get_subscription_customer_id(
|
||||
payment_options
|
||||
)
|
||||
if not customer_id:
|
||||
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
|
||||
payload["customer_id"] = customer_id
|
||||
r = await self.client.post(
|
||||
"/api/subscriptions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
revolut_subscription_id = data.get("id")
|
||||
setup_order_id = data.get("setup_order_id")
|
||||
if not revolut_subscription_id or not setup_order_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False,
|
||||
error_message=(
|
||||
"Server error: missing subscription id or setup order id"
|
||||
),
|
||||
)
|
||||
|
||||
setup_order = await self.get_order(setup_order_id)
|
||||
checkout_url = setup_order.get("checkout_url")
|
||||
if not checkout_url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing setup checkout url"
|
||||
)
|
||||
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=checkout_url,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
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:
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"/api/subscriptions/{subscription_id}/cancel",
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
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("Revolut does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
order_id = self._normalize_revolut_id(checking_id)
|
||||
return self._status_from_order(await self.get_order(order_id))
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting Revolut invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("Revolut does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"Revolut 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 _normalize_revolut_id(self, checking_id: str) -> str:
|
||||
value = (
|
||||
checking_id.replace("fiat_revolut_", "", 1)
|
||||
if checking_id.startswith("fiat_revolut_")
|
||||
else checking_id
|
||||
)
|
||||
return value.replace("order_", "", 1) if value.startswith("order_") else value
|
||||
|
||||
async def get_order(self, order_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def get_subscription_cycle(
|
||||
self, subscription_id: str, cycle_id: str
|
||||
) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def _get_subscription_customer_id(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not payment_options.customer_email:
|
||||
return (
|
||||
None,
|
||||
"Revolut subscriptions require customer_email.",
|
||||
)
|
||||
|
||||
customer = await self._get_customer_by_email(payment_options.customer_email)
|
||||
customer_id = customer.get("id") if customer else None
|
||||
if customer_id:
|
||||
return customer_id, None
|
||||
|
||||
customer = await self._create_customer(payment_options.customer_email)
|
||||
customer_id = customer.get("id")
|
||||
if not customer_id:
|
||||
return None, "Server error: missing customer id"
|
||||
return customer_id, None
|
||||
|
||||
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
|
||||
page_token = None
|
||||
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
|
||||
customer_page = await self._list_customers(page_token=page_token)
|
||||
customer = _find_customer_by_email(customer_page["customers"], email)
|
||||
if customer:
|
||||
return customer
|
||||
|
||||
page_token = customer_page.get("next_page_token")
|
||||
if not page_token:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
r = await self.client.get(
|
||||
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return _extract_customer_page(r.json())
|
||||
|
||||
async def _create_customer(self, email: str) -> dict[str, Any]:
|
||||
r = await self.client.post(
|
||||
"/api/customers",
|
||||
json={"email": email},
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@classmethod
|
||||
async def create_webhook(
|
||||
cls,
|
||||
url: str,
|
||||
endpoint: str | None = None,
|
||||
api_secret_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not url:
|
||||
raise ValueError("Missing Revolut webhook URL.")
|
||||
cls._validate_webhook_url(url)
|
||||
if not endpoint and not settings.revolut_api_endpoint:
|
||||
raise ValueError("Missing Revolut API endpoint.")
|
||||
if not api_secret_key and not settings.revolut_api_secret_key:
|
||||
raise ValueError("Missing Revolut API secret key.")
|
||||
|
||||
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
|
||||
secret_key = api_secret_key or settings.revolut_api_secret_key
|
||||
headers = {
|
||||
"Authorization": f"Bearer {secret_key}",
|
||||
"Revolut-Api-Version": api_version or settings.revolut_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
|
||||
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
|
||||
webhooks = await cls._list_webhooks(client)
|
||||
existing = await cls._get_existing_webhook(client, webhooks, url)
|
||||
if existing:
|
||||
existing["already_exists"] = True
|
||||
return existing
|
||||
|
||||
response = await client.post(
|
||||
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@classmethod
|
||||
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
|
||||
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for field in ["webhooks", "data", "items"]:
|
||||
if isinstance(data.get(field), list):
|
||||
return data[field]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def _get_existing_webhook(
|
||||
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
|
||||
) -> dict[str, Any] | None:
|
||||
for webhook in webhooks:
|
||||
if cls._normalize_webhook_url(webhook.get("url")) != (
|
||||
cls._normalize_webhook_url(url)
|
||||
):
|
||||
continue
|
||||
|
||||
webhook_id = webhook.get("id")
|
||||
if webhook_id and (
|
||||
not webhook.get("events") or not webhook.get("signing_secret")
|
||||
):
|
||||
response = await client.get(
|
||||
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
webhook = response.json()
|
||||
|
||||
events = set(webhook.get("events") or [])
|
||||
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
|
||||
if missing_events:
|
||||
raise ValueError(
|
||||
"A Revolut webhook already exists for this URL, but it is "
|
||||
f"missing required events: {', '.join(sorted(missing_events))}."
|
||||
)
|
||||
|
||||
if not webhook.get("signing_secret"):
|
||||
raise ValueError(
|
||||
"A Revolut webhook already exists for this URL, but Revolut "
|
||||
"did not return a signing secret."
|
||||
)
|
||||
|
||||
return webhook
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _normalize_webhook_url(cls, url: str | None) -> str:
|
||||
return (url or "").strip().rstrip("/")
|
||||
|
||||
@classmethod
|
||||
def _validate_webhook_url(cls, url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if parsed.scheme not in ["http", "https"] or not hostname:
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
host = hostname.lower()
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
if host.endswith(".local") or host.endswith(".onion"):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (order.get("state") or "").upper()
|
||||
if status == "COMPLETED":
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["CANCELLED", "FAILED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
@classmethod
|
||||
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
|
||||
scale = Decimal(10) ** cls.currency_exponent(currency)
|
||||
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
|
||||
|
||||
@classmethod
|
||||
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
|
||||
scale = Decimal(10) ** cls.currency_exponent(currency)
|
||||
return float(Decimal(amount) / scale)
|
||||
|
||||
@classmethod
|
||||
def currency_exponent(cls, currency: str) -> int:
|
||||
normalized = currency.upper()
|
||||
if normalized in ZERO_DECIMAL_CURRENCIES:
|
||||
return 0
|
||||
if normalized in THREE_DECIMAL_CURRENCIES:
|
||||
return 3
|
||||
return 2
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> RevolutCreateInvoiceOptions | None:
|
||||
try:
|
||||
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid Revolut options: {e}")
|
||||
return None
|
||||
|
||||
def _serialize_subscription_reference(
|
||||
self, reference: RevolutSubscriptionReference
|
||||
) -> str:
|
||||
payload = reference.dict(exclude_none=True)
|
||||
serialized = json.dumps(payload, separators=(",", ":"))
|
||||
if len(serialized) > 1024:
|
||||
raise ValueError("Revolut subscription external_reference is too long.")
|
||||
return serialized
|
||||
|
||||
def deserialize_subscription_reference(
|
||||
self, external_reference: str | None
|
||||
) -> RevolutSubscriptionReference | None:
|
||||
if not external_reference:
|
||||
return None
|
||||
try:
|
||||
return RevolutSubscriptionReference.parse_obj(
|
||||
json.loads(external_reference)
|
||||
)
|
||||
except (json.JSONDecodeError, ValidationError) as exc:
|
||||
logger.warning(exc)
|
||||
return None
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.revolut_api_endpoint),
|
||||
str(settings.revolut_api_secret_key),
|
||||
str(settings.revolut_api_version),
|
||||
str(settings.revolut_webhook_signing_secret),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _extract_customer_page(data: Any) -> dict[str, Any]:
|
||||
if isinstance(data, list):
|
||||
return {"customers": _filter_customer_list(data)}
|
||||
if isinstance(data, dict):
|
||||
for field in ["customers", "data", "items"]:
|
||||
customers = data.get(field)
|
||||
if isinstance(customers, list):
|
||||
return {
|
||||
"customers": _filter_customer_list(customers),
|
||||
"next_page_token": data.get("next_page_token"),
|
||||
}
|
||||
return {"customers": []}
|
||||
|
||||
|
||||
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
|
||||
return [customer for customer in customers if isinstance(customer, dict)]
|
||||
|
||||
|
||||
def _find_customer_by_email(
|
||||
customers: list[dict[str, Any]], email: str
|
||||
) -> dict[str, Any] | None:
|
||||
normalized_email = email.casefold()
|
||||
for customer in customers:
|
||||
if str(customer.get("email") or "").casefold() == normalized_email:
|
||||
return customer
|
||||
return None
|
||||
@@ -1,620 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
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 SquareCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
line_item_name: str | None = None
|
||||
|
||||
|
||||
class SquareSubscriptionOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checking_id: str | None = None
|
||||
payment_request: str | None = None
|
||||
|
||||
|
||||
class SquareCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
fiat_method: FiatMethod = "checkout"
|
||||
checkout: SquareCheckoutOptions | None = None
|
||||
subscription: SquareSubscriptionOptions | None = None
|
||||
|
||||
|
||||
class SquareSubscriptionCheckoutInfo(BaseModel):
|
||||
plan_variation_id: str
|
||||
price_money: dict[str, Any]
|
||||
|
||||
|
||||
class SquareWallet(FiatProvider):
|
||||
"""https://developer.squareup.com/reference/square"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing SquareWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.square_api_endpoint:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
|
||||
if not settings.square_access_token:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing access token.")
|
||||
if not settings.square_location_id:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
|
||||
self.location_id = settings.square_location_id
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.square_access_token}",
|
||||
"Square-Version": settings.square_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("SquareWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing Square 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:
|
||||
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
|
||||
r.raise_for_status()
|
||||
_ = r.json()
|
||||
return FiatStatusResponse(balance=0)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 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 not opts:
|
||||
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
|
||||
|
||||
if opts.fiat_method == "subscription":
|
||||
return self._create_subscription_invoice(opts.subscription)
|
||||
|
||||
return await self._create_checkout_invoice(
|
||||
amount=amount,
|
||||
payment_hash=payment_hash,
|
||||
currency=currency,
|
||||
opts=opts,
|
||||
memo=memo,
|
||||
)
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
quantity: int,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
if settings.lnbits_running:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Subscription not supported for Square."
|
||||
)
|
||||
success_url = (
|
||||
payment_options.success_url
|
||||
or settings.square_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
|
||||
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:
|
||||
checkout_info = await self._get_subscription_checkout_info(subscription_id)
|
||||
metadata = self._serialize_metadata(payment_options)
|
||||
payload = {
|
||||
"idempotency_key": payment_options.subscription_request_id,
|
||||
"description": metadata,
|
||||
"quick_pay": {
|
||||
"name": (payment_options.memo or "LNbits Subscription")[:255],
|
||||
"price_money": checkout_info.price_money,
|
||||
"location_id": self.location_id,
|
||||
},
|
||||
"checkout_options": {
|
||||
"redirect_url": success_url,
|
||||
"subscription_plan_id": checkout_info.plan_variation_id,
|
||||
},
|
||||
"payment_note": metadata,
|
||||
}
|
||||
r = await self.client.post(
|
||||
"/v2/online-checkout/payment-links", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
payment_link = data.get("payment_link") or {}
|
||||
url = payment_link.get("url")
|
||||
if not url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing url"
|
||||
)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=url,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
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:
|
||||
try:
|
||||
square_subscription_id = await self._get_square_subscription_id(
|
||||
subscription_id, correlation_id
|
||||
)
|
||||
r = await self.client.post(
|
||||
f"/v2/subscriptions/{square_subscription_id}/cancel"
|
||||
)
|
||||
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("Square does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
square_id = self._normalize_square_id(checking_id)
|
||||
if square_id.startswith("payment_"):
|
||||
payment_id = square_id.replace("payment_", "", 1)
|
||||
return await self._get_payment_status(payment_id)
|
||||
|
||||
order_id = (
|
||||
square_id.replace("order_", "", 1)
|
||||
if square_id.startswith("order_")
|
||||
else square_id
|
||||
)
|
||||
return await self._get_order_status(order_id)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting Square invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("Square does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"Square 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
|
||||
|
||||
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
|
||||
order = await self._get_order(order_id)
|
||||
payment_id = self._payment_id_from_order(order)
|
||||
if payment_id:
|
||||
return await self._get_payment_status(payment_id)
|
||||
|
||||
if (order.get("state") or "").upper() == "CANCELED":
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def _get_order(self, order_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/orders/{order_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("order") or {}
|
||||
|
||||
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
|
||||
order = await self._get_order(order_id)
|
||||
payment_id = self._payment_id_from_order(order)
|
||||
if not payment_id:
|
||||
return None
|
||||
return await self._get_payment(payment_id)
|
||||
|
||||
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
|
||||
tenders = order.get("tenders") or []
|
||||
for tender in tenders:
|
||||
payment_id = tender.get("payment_id")
|
||||
if payment_id:
|
||||
return payment_id
|
||||
return None
|
||||
|
||||
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
|
||||
return self._status_from_payment(await self._get_payment(payment_id))
|
||||
|
||||
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/payments/{payment_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("payment") or {}
|
||||
|
||||
async def _get_subscription_checkout_info(
|
||||
self, subscription_plan_id: str
|
||||
) -> SquareSubscriptionCheckoutInfo:
|
||||
catalog_object = await self._get_catalog_object(subscription_plan_id)
|
||||
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
|
||||
return await self._get_plan_checkout_info(catalog_object)
|
||||
|
||||
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
|
||||
price_money = await self._get_subscription_price_money(
|
||||
catalog_object,
|
||||
)
|
||||
plan_variation_id = catalog_object.get("id")
|
||||
if not plan_variation_id:
|
||||
raise ValueError("Square subscription plan variation is missing an ID.")
|
||||
return SquareSubscriptionCheckoutInfo(
|
||||
plan_variation_id=plan_variation_id,
|
||||
price_money=price_money,
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
"Square subscription ID must be a plan ID or plan variation ID."
|
||||
)
|
||||
|
||||
async def _get_plan_checkout_info(
|
||||
self, catalog_object: dict[str, Any]
|
||||
) -> SquareSubscriptionCheckoutInfo:
|
||||
plan_data = catalog_object.get("subscription_plan_data") or {}
|
||||
plan_variations = plan_data.get("subscription_plan_variations") or []
|
||||
eligible_item_ids = plan_data.get("eligible_item_ids") or []
|
||||
plan_variation = next(
|
||||
(
|
||||
variation
|
||||
for variation in plan_variations
|
||||
if not variation.get("is_deleted")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not plan_variation:
|
||||
raise ValueError("Square subscription plan is missing a variation.")
|
||||
|
||||
price_money = await self._get_subscription_price_money(
|
||||
plan_variation,
|
||||
eligible_item_ids=eligible_item_ids,
|
||||
)
|
||||
plan_variation_id = plan_variation.get("id")
|
||||
if not plan_variation_id:
|
||||
raise ValueError("Square subscription plan variation is missing an ID.")
|
||||
|
||||
return SquareSubscriptionCheckoutInfo(
|
||||
plan_variation_id=plan_variation_id,
|
||||
price_money=price_money,
|
||||
)
|
||||
|
||||
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/catalog/object/{object_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("object") or {}
|
||||
|
||||
async def _get_subscription_price_money(
|
||||
self,
|
||||
plan_variation: dict[str, Any],
|
||||
eligible_item_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
|
||||
phases = variation_data.get("phases") or []
|
||||
for phase in phases:
|
||||
pricing = phase.get("pricing") or {}
|
||||
price_money = pricing.get("price_money") or phase.get(
|
||||
"recurring_price_money"
|
||||
)
|
||||
parsed_price_money = self._parse_price_money(price_money)
|
||||
if parsed_price_money:
|
||||
return parsed_price_money
|
||||
|
||||
if pricing.get("type") == "RELATIVE":
|
||||
return await self._get_relative_subscription_price_money(
|
||||
eligible_item_ids or []
|
||||
)
|
||||
|
||||
raise ValueError("Square subscription plan variation is missing price_money.")
|
||||
|
||||
async def _get_relative_subscription_price_money(
|
||||
self, eligible_item_ids: list[str]
|
||||
) -> dict[str, Any]:
|
||||
if len(eligible_item_ids) != 1:
|
||||
raise ValueError(
|
||||
"Square relative subscription plan must have exactly one item."
|
||||
)
|
||||
|
||||
item = await self._get_catalog_object(eligible_item_ids[0])
|
||||
item_variations: list[dict[str, Any]] = []
|
||||
if item.get("type") == "ITEM":
|
||||
item_variations = (item.get("item_data") or {}).get("variations") or []
|
||||
elif item.get("type") == "ITEM_VARIATION":
|
||||
item_variations = [item]
|
||||
|
||||
item_variation = next(
|
||||
(
|
||||
variation
|
||||
for variation in item_variations
|
||||
if not variation.get("is_deleted")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not item_variation:
|
||||
raise ValueError("Square subscription item is missing a variation.")
|
||||
|
||||
price_money = self._parse_price_money(
|
||||
(item_variation.get("item_variation_data") or {}).get("price_money")
|
||||
)
|
||||
if price_money:
|
||||
return price_money
|
||||
|
||||
raise ValueError("Square subscription item variation is missing price_money.")
|
||||
|
||||
def _parse_price_money(
|
||||
self, price_money: dict[str, Any] | None
|
||||
) -> dict[str, Any] | None:
|
||||
if (
|
||||
price_money
|
||||
and price_money.get("amount") is not None
|
||||
and price_money.get("currency")
|
||||
):
|
||||
return {
|
||||
"amount": int(price_money["amount"]),
|
||||
"currency": price_money["currency"].upper(),
|
||||
}
|
||||
return None
|
||||
|
||||
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (payment.get("status") or "").upper()
|
||||
if status == "COMPLETED":
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["CANCELED", "FAILED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def _create_checkout_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
opts: SquareCreateInvoiceOptions,
|
||||
memo: str | None = None,
|
||||
) -> FiatInvoiceResponse:
|
||||
amount_cents = int(amount * 100)
|
||||
co = opts.checkout or SquareCheckoutOptions()
|
||||
success_url = (
|
||||
co.success_url
|
||||
or settings.square_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
|
||||
metadata = {
|
||||
**co.metadata,
|
||||
"payment_hash": payment_hash,
|
||||
"alan_action": "invoice",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"idempotency_key": payment_hash,
|
||||
"order": {
|
||||
"location_id": self.location_id,
|
||||
"metadata": metadata,
|
||||
"line_items": [
|
||||
{
|
||||
"name": line_item_name,
|
||||
"quantity": "1",
|
||||
"base_price_money": {
|
||||
"amount": amount_cents,
|
||||
"currency": currency.upper(),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"checkout_options": {"redirect_url": success_url},
|
||||
}
|
||||
if memo:
|
||||
payload["payment_note"] = memo[:500]
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v2/online-checkout/payment-links", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
payment_link = data.get("payment_link") or {}
|
||||
order_id = payment_link.get("order_id")
|
||||
url = payment_link.get("url")
|
||||
if not order_id or not url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing order id or url"
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"order_{order_id}",
|
||||
payment_request=url,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
def _create_subscription_invoice(
|
||||
self, opts: SquareSubscriptionOptions | None
|
||||
) -> FiatInvoiceResponse:
|
||||
term = opts or SquareSubscriptionOptions()
|
||||
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=term.payment_request or "",
|
||||
)
|
||||
|
||||
def _normalize_square_id(self, checking_id: str) -> str:
|
||||
return (
|
||||
checking_id.replace("fiat_square_", "", 1)
|
||||
if checking_id.startswith("fiat_square_")
|
||||
else checking_id
|
||||
)
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> SquareCreateInvoiceOptions | None:
|
||||
try:
|
||||
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid Square options: {e}")
|
||||
return None
|
||||
|
||||
def _serialize_metadata(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> str:
|
||||
extra_link = None
|
||||
if payment_options.extra:
|
||||
raw_link = payment_options.extra.get("link")
|
||||
extra_link = str(raw_link)[:200] if raw_link else None
|
||||
|
||||
meta = [
|
||||
payment_options.wallet_id,
|
||||
payment_options.tag,
|
||||
payment_options.subscription_request_id,
|
||||
extra_link,
|
||||
]
|
||||
|
||||
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
|
||||
if memo_limit > 0 and payment_options.memo:
|
||||
meta.append(payment_options.memo[:memo_limit])
|
||||
else:
|
||||
meta.append(None)
|
||||
|
||||
metadata = json.dumps(meta, separators=(",", ":"))
|
||||
if len(metadata) > 500:
|
||||
raise ValueError("Square subscription metadata is too long.")
|
||||
return metadata
|
||||
|
||||
async def _get_square_subscription_id(
|
||||
self, subscription_id: str, wallet_id: str
|
||||
) -> str:
|
||||
try:
|
||||
from lnbits.core.crud.payments import get_payments
|
||||
from lnbits.core.models import PaymentFilters
|
||||
from lnbits.db import Filter, Filters
|
||||
|
||||
payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
filters=Filters(
|
||||
filters=[
|
||||
Filter.parse_query(
|
||||
"external_id", [subscription_id], PaymentFilters
|
||||
)
|
||||
],
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
limit=1,
|
||||
),
|
||||
)
|
||||
payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in payments
|
||||
if payment.external_id and payment.fiat_provider == "square"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if payment and payment.external_id:
|
||||
return payment.external_id
|
||||
|
||||
payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
incoming=True,
|
||||
filters=Filters(
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
),
|
||||
)
|
||||
payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in payments
|
||||
if payment.external_id
|
||||
and payment.fiat_provider == "square"
|
||||
and (payment.extra or {}).get("subscription_request_id")
|
||||
== subscription_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if payment and payment.external_id:
|
||||
return payment.external_id
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
|
||||
return subscription_id
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.square_api_endpoint),
|
||||
str(settings.square_access_token),
|
||||
str(settings.square_location_id),
|
||||
str(settings.square_api_version),
|
||||
]
|
||||
)
|
||||
+54
-18
@@ -7,14 +7,15 @@ 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
|
||||
@@ -55,6 +56,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 +66,60 @@ 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_APPLE_TOUCH_ICON": settings.lnbits_apple_touch_icon,
|
||||
"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,
|
||||
"LNBITS_CURRENCIES": list(currencies.keys()),
|
||||
"LNBITS_ALLOWED_CURRENCIES": settings.lnbits_allowed_currencies,
|
||||
"CACHE_KEY": settings.server_startup_time,
|
||||
}
|
||||
|
||||
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 +133,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 +390,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
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from loguru import logger
|
||||
from pyinstrument import Profiler
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
|
||||
):
|
||||
return RedirectResponse("/first_install")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def add_profiler_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
async def profile_middleware(request: Request, call_next):
|
||||
profiling = request.query_params.get("profiler", False)
|
||||
if profiling:
|
||||
profiler = Profiler(async_mode="enabled")
|
||||
profiler.start()
|
||||
_ = await call_next(request)
|
||||
profiler.stop()
|
||||
return HTMLResponse(profiler.output_html())
|
||||
return await call_next(request)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+6
-254
@@ -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(
|
||||
@@ -284,7 +264,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
@@ -292,15 +272,10 @@ class ThemesSettings(LNbitsSettings):
|
||||
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)
|
||||
lnbits_default_burger_menu_background: bool = Field(default=True)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -444,7 +419,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 (
|
||||
@@ -585,8 +559,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):
|
||||
@@ -606,16 +578,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)
|
||||
@@ -699,35 +661,6 @@ class PayPalFiatProvider(LNbitsSettings):
|
||||
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class SquareFiatProvider(LNbitsSettings):
|
||||
square_enabled: bool = Field(default=False)
|
||||
square_api_endpoint: str = Field(default="https://connect.squareup.com")
|
||||
square_access_token: str | None = Field(default=None)
|
||||
square_location_id: str | None = Field(default=None)
|
||||
square_api_version: str = Field(default="2026-01-22")
|
||||
square_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
square_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
|
||||
)
|
||||
square_webhook_signature_key: str | None = Field(default=None)
|
||||
|
||||
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class RevolutFiatProvider(LNbitsSettings):
|
||||
revolut_enabled: bool = Field(default=False)
|
||||
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
|
||||
revolut_api_secret_key: str | None = Field(default=None)
|
||||
revolut_api_version: str = Field(default="2026-04-20")
|
||||
revolut_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
revolut_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
|
||||
)
|
||||
revolut_webhook_signing_secret: str | None = Field(default=None)
|
||||
|
||||
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -750,7 +683,6 @@ class FundingSourcesSettings(
|
||||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
SparkL2FundingSource,
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
@@ -761,16 +693,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,
|
||||
SquareFiatProvider,
|
||||
RevolutFiatProvider,
|
||||
):
|
||||
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
@@ -781,10 +707,6 @@ class FiatProvidersSettings(
|
||||
return self.stripe_enabled
|
||||
if provider == "paypal":
|
||||
return self.paypal_enabled
|
||||
if provider == "square":
|
||||
return self.square_enabled
|
||||
if provider == "revolut":
|
||||
return self.revolut_enabled
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
@@ -804,18 +726,6 @@ class FiatProvidersSettings(
|
||||
):
|
||||
allowed_providers.append("paypal")
|
||||
|
||||
if self.square_enabled and (
|
||||
not self.square_limits.allowed_users
|
||||
or user_id in self.square_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("square")
|
||||
|
||||
if self.revolut_enabled and (
|
||||
not self.revolut_limits.allowed_users
|
||||
or user_id in self.revolut_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("revolut")
|
||||
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
@@ -847,7 +757,6 @@ class AuthMethods(Enum):
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
keycloak_auth = "keycloak-auth"
|
||||
oidc_auth = "oidc-auth"
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
@@ -858,7 +767,6 @@ class AuthMethods(Enum):
|
||||
AuthMethods.google_auth.value,
|
||||
AuthMethods.github_auth.value,
|
||||
AuthMethods.keycloak_auth.value,
|
||||
AuthMethods.oidc_auth.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -904,14 +812,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)
|
||||
|
||||
@@ -1019,7 +919,6 @@ class EditableSettings(
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
OidcAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -1047,7 +946,7 @@ class EditableSettings(
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config(EditableSettings.Config):
|
||||
class Config:
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
@@ -1055,9 +954,6 @@ class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
profiler: bool = Field(default=False)
|
||||
# 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="*")
|
||||
@@ -1071,24 +967,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
|
||||
@@ -1124,7 +1010,6 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"FakeWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"SparkL2Wallet",
|
||||
"LnTipsWallet",
|
||||
"LndRestWallet",
|
||||
"LndWallet",
|
||||
@@ -1198,11 +1083,11 @@ class ReadOnlySettings(
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback # type: ignore[assignment]
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
return (
|
||||
@@ -1230,139 +1115,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")
|
||||
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
|
||||
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,
|
||||
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
|
||||
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
+18
-20
File diff suppressed because one or more lines are too long
+29
-3
@@ -1,5 +1,30 @@
|
||||
window.app.component('lnbits-error', {
|
||||
template: '#lnbits-error',
|
||||
<template>
|
||||
<div class="text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div class="error-code" v-text="code"></div>
|
||||
<div class="error-message" v-text="message"></div>
|
||||
<div class="q-mx-auto q-mt-lg justify-center" style="width: max-content">
|
||||
<q-btn
|
||||
v-if="isExtension"
|
||||
color="primary"
|
||||
@click="goToExtension()"
|
||||
label="Go To Extension"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else-if="g.isUserAuthorized"
|
||||
color="primary"
|
||||
@click="goToWallet()"
|
||||
label="Go to Wallet"
|
||||
></q-btn>
|
||||
<q-btn v-else color="primary" @click="goBack()" label="Go Back"></q-btn>
|
||||
<span class="q-mx-md">OR</span>
|
||||
<q-btn color="secondary" @click="goHome()" label="Go Home"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['dynamic', 'code', 'message'],
|
||||
computed: {
|
||||
isExtension() {
|
||||
@@ -49,4 +74,5 @@ window.app.component('lnbits-error', {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
import LnbitsError from './lnbits-error.vue'
|
||||
|
||||
window.LnbitsError = LnbitsError
|
||||
+2
-187
@@ -212,197 +212,12 @@ body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
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: 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) {
|
||||
box-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) {
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
body.no-burger-background .q-drawer {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user