mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
Compare commits
167 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9495854d5c | ||
|
|
43e42eb3b3 | ||
|
|
e6c7ebdf42 | ||
|
|
5592a602da | ||
|
|
a97c75ecdc | ||
|
|
02d87445bf | ||
|
|
d1a431afc8 | ||
|
|
04c130dca3 | ||
|
|
51e585c78c | ||
|
|
f981ee2bca | ||
|
|
e6a61fafbb | ||
|
|
bae9e1bf80 | ||
|
|
8b0413fa16 | ||
|
|
0a7da6dc73 | ||
|
|
0bdb5dc9a8 | ||
|
|
37430f6a2a | ||
|
|
fbf43e29fb | ||
|
|
68429fb364 | ||
|
|
7571ebd9a1 | ||
|
|
9123db3a0b | ||
|
|
9aedfdcc9b | ||
|
|
b15478ac2c | ||
|
|
9b558db477 | ||
|
|
e165269ed8 | ||
|
|
7258bd32b4 | ||
|
|
2b5a513693 | ||
|
|
7e2e116d46 | ||
|
|
84da727282 | ||
|
|
5c1cf66ef3 | ||
|
|
c7d9461749 | ||
|
|
8723d327fe | ||
|
|
c0806ca59c | ||
|
|
d9523aadbd | ||
|
|
dc46bddc80 | ||
|
|
03daf8b218 | ||
|
|
eee372bb3c | ||
|
|
2223733c2d | ||
|
|
f3f5c89113 | ||
|
|
91c8aa77ed | ||
|
|
368d0f548f | ||
|
|
afa7e1a2bd | ||
|
|
653924a6dc | ||
|
|
39e123d391 | ||
|
|
d07088cc44 | ||
|
|
20115b42aa | ||
|
|
4326888a6e | ||
|
|
9431cef74c | ||
|
|
c5b5651d8f | ||
|
|
9177e2262a | ||
|
|
b5454f3a18 | ||
|
|
f5989201d9 | ||
|
|
a85a5144d0 | ||
|
|
84bf855810 | ||
|
|
09c6e7239d | ||
|
|
53a1ae5e53 | ||
|
|
61ed636df0 | ||
|
|
43900dd6da | ||
|
|
06c553219a | ||
|
|
fc73d83bd9 | ||
|
|
4cb743d952 | ||
|
|
3b6e87b060 | ||
|
|
09e44f18e5 | ||
|
|
cabb58f8fe | ||
|
|
aac04efd0e | ||
|
|
fd9009f760 | ||
|
|
6ab413775a | ||
|
|
7eb9965205 | ||
|
|
4634ad5a5a | ||
|
|
648aaa17c5 | ||
|
|
7db5c986b3 | ||
|
|
26c31b626d | ||
|
|
e39cb20525 | ||
|
|
3cea9a90d1 | ||
|
|
3224e2e774 | ||
|
|
cfc9874517 | ||
|
|
62cd151fd6 | ||
|
|
a4d0aa5db8 | ||
|
|
77630781d7 | ||
|
|
59da350cc9 | ||
|
|
29e980dd67 | ||
|
|
9d9ce63c82 | ||
|
|
748f458b8b | ||
|
|
ce177a73b1 | ||
|
|
2885e71be2 | ||
|
|
4b6f43d274 | ||
|
|
86b5cf9421 | ||
|
|
8f5b7d85aa | ||
|
|
4d51e63924 | ||
|
|
0ce2501e1a | ||
|
|
f2351145f0 | ||
|
|
2eb7d67b2a | ||
|
|
a82093b7ec | ||
|
|
ee595eede1 | ||
|
|
dfdce54e57 | ||
|
|
1367480ec6 | ||
|
|
e2d83b516a | ||
|
|
83699289fc | ||
|
|
f04e88d8bf | ||
|
|
564edfc447 | ||
|
|
b98515df14 | ||
|
|
1e0fc84586 | ||
|
|
a1d94834ae | ||
|
|
5de4239f3c | ||
|
|
c404666d7f | ||
|
|
190a466c0a | ||
|
|
d01e3523d8 | ||
|
|
88672501d8 | ||
|
|
ce57d08163 | ||
|
|
52304e0730 | ||
|
|
6664eebf5a | ||
|
|
2a2af81827 | ||
|
|
9b47f6323f | ||
|
|
a61807a257 | ||
|
|
30e0522419 | ||
|
|
8f033a6047 | ||
|
|
a2c817a56b | ||
|
|
555350085e | ||
|
|
fc5061a67f | ||
|
|
c9c68bd8d7 | ||
|
|
810a13722c | ||
|
|
36d696b222 | ||
|
|
8b426efa3e | ||
|
|
9edc4786e1 | ||
|
|
93dc10fe94 | ||
|
|
6c8448d7a8 | ||
|
|
99e4f33142 | ||
|
|
f4f43ad361 | ||
|
|
f14ea6c577 | ||
|
|
07428ecf94 | ||
|
|
867e3d06f6 | ||
|
|
4b4b6d0bcd | ||
|
|
07b1521dad | ||
|
|
7a2ddd9826 | ||
|
|
0eb4b477b7 | ||
|
|
06a0ba58ce | ||
|
|
5399b36027 | ||
|
|
385fb4f9bc | ||
|
|
8db76b8864 | ||
|
|
9e3ab0ef26 | ||
|
|
04c9b67997 | ||
|
|
116f982aab | ||
|
|
63cc89e2b6 | ||
|
|
183e6e5661 | ||
|
|
6b3fd80e46 | ||
|
|
5999a773be | ||
|
|
7e0fadad3b | ||
|
|
b2d6243697 | ||
|
|
6b5a77fb3b | ||
|
|
cc8fb68b02 | ||
|
|
658da6b28e | ||
|
|
7d734ecb74 | ||
|
|
9177dd195b | ||
|
|
15faab4f38 | ||
|
|
2cce687865 | ||
|
|
313bd3f647 | ||
|
|
13a93836d9 | ||
|
|
4f76d0483e | ||
|
|
719d86aa9c | ||
|
|
bbad4a91ae | ||
|
|
fcebb7e28c | ||
|
|
ce5aa4c8a7 | ||
|
|
75bae67446 | ||
|
|
8c184356ef | ||
|
|
efc0547271 | ||
|
|
7a393b11fd | ||
|
|
fae3eca3c7 | ||
|
|
3398070dd5 |
347 changed files with 63203 additions and 31072 deletions
69
.env.example
69
.env.example
|
|
@ -7,14 +7,20 @@
|
|||
# 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
|
||||
# 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
|
||||
|
|
@ -24,6 +30,10 @@ 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 ===
|
||||
|
||||
|
|
@ -59,7 +69,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, BarkWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
|
||||
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
|
|
@ -70,6 +80,9 @@ FORWARDED_ALLOW_IPS="*"
|
|||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
# Path where WASM extensions will be installed (defaults to `LNBITS_DATA_FOLDER/wasm_extensions`).
|
||||
# LNBITS_WASM_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
# ID of the super user. The user ID must exist.
|
||||
# SUPER_USER=""
|
||||
|
||||
|
|
@ -94,7 +107,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
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
|
|
@ -109,6 +122,8 @@ 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"
|
||||
|
||||
######################################
|
||||
|
|
@ -132,6 +147,10 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
|||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# BarkWallet
|
||||
BARK_API_ENDPOINT=http://localhost:3000
|
||||
BARK_API_TOKEN=auth_token
|
||||
|
||||
#CLNRest (using runes)
|
||||
CLNREST_URL=https://127.0.0.1:3010
|
||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
||||
|
|
@ -271,6 +290,50 @@ 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
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
|
|
|
|||
18
.github/workflows/appimage.yml
vendored
18
.github/workflows/appimage.yml
vendored
|
|
@ -7,10 +7,6 @@ on:
|
|||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
|
@ -18,10 +14,6 @@ on:
|
|||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
|
|
@ -69,7 +61,10 @@ 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 \
|
||||
|
|
@ -110,11 +105,6 @@ jobs:
|
|||
shell: bash
|
||||
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
|
||||
|
|
|
|||
33
.github/workflows/bundle.yml
vendored
Normal file
33
.github/workflows/bundle.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: bundle
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- name: Build and commit bundle (same-repo PR)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
make bundle
|
||||
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
|
||||
- name: Check bundle is up-to-date (fork PR)
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: make checkbundle
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
|
|
@ -1,14 +1,9 @@
|
|||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
|
|
@ -16,7 +11,7 @@ jobs:
|
|||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
|
|
@ -30,7 +25,7 @@ jobs:
|
|||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
|
|
@ -44,7 +39,7 @@ jobs:
|
|||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
|
|
@ -58,7 +53,7 @@ jobs:
|
|||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
|
@ -69,12 +64,17 @@ jobs:
|
|||
with:
|
||||
make: openapi
|
||||
|
||||
test-e2e:
|
||||
if: ${{ false }}
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
|
|
@ -94,7 +94,11 @@ jobs:
|
|||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
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
|
||||
|
|
|
|||
41
.github/workflows/e2e.yml
vendored
Normal file
41
.github/workflows/e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: e2e
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-e2e:
|
||||
name: test-e2e (${{ matrix.name }})
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: bigpayment
|
||||
spec: tests/e2e/bigpayment.spec.ts
|
||||
- name: paysplit
|
||||
spec: tests/e2e/paysplit.spec.ts
|
||||
- name: pingpong
|
||||
spec: tests/e2e/pingpong.spec.ts
|
||||
- name: tips
|
||||
spec: tests/e2e/tips.spec.ts
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.12"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- name: Install Playwright browser
|
||||
run: npm exec playwright install chromium
|
||||
- name: Run ${{ matrix.name }} e2e tests
|
||||
run: npm run test:e2e -- "${{ matrix.spec }}"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-${{ matrix.name }}
|
||||
path: test-reports
|
||||
if-no-files-found: ignore
|
||||
1
.github/workflows/jmeter.yml
vendored
1
.github/workflows/jmeter.yml
vendored
|
|
@ -22,6 +22,7 @@ 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: |
|
||||
|
|
|
|||
23
.github/workflows/lint.yml
vendored
23
.github/workflows/lint.yml
vendored
|
|
@ -6,53 +6,30 @@ 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
|
||||
|
|
|
|||
4
.github/workflows/make.yml
vendored
4
.github/workflows/make.yml
vendored
|
|
@ -14,7 +14,7 @@ on:
|
|||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
node-version: ["24.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
|||
3
.github/workflows/regtest.yml
vendored
3
.github/workflows/regtest.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
|
@ -66,6 +66,7 @@ jobs:
|
|||
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
|
||||
ECLAIR_PASS: lnbits
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
|
|
|
|||
11
.github/workflows/release-rc.yml
vendored
11
.github/workflows/release-rc.yml
vendored
|
|
@ -12,8 +12,6 @@ jobs:
|
|||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github pre-release
|
||||
|
|
@ -22,14 +20,6 @@ jobs:
|
|||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --prerelease --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
|
|
@ -74,4 +64,3 @@ jobs:
|
|||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -13,8 +13,6 @@ jobs:
|
|||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
|
|
@ -23,14 +21,6 @@ jobs:
|
|||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
|
|
@ -85,4 +75,3 @@ jobs:
|
|||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
|
|
|||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ __pycache__
|
|||
*$py.class
|
||||
.mypy_cache
|
||||
.vscode
|
||||
.codex
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
|
|
|
|||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
min-release-age=7
|
||||
|
|
@ -14,11 +14,11 @@ repos:
|
|||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.10
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
|
|
|
|||
60
AGENTS.md
Normal file
60
AGENTS.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# 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.
|
||||
|
|
@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
|||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
|
|
|||
35
Makefile
35
Makefile
|
|
@ -62,10 +62,15 @@ test-api:
|
|||
DEBUG=true \
|
||||
uv run pytest tests/api
|
||||
|
||||
test-e2e:
|
||||
npm exec playwright install chromium
|
||||
npm run test:e2e
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
rm -rf ./tests/data \
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
|
|
@ -88,16 +93,36 @@ migration:
|
|||
uv run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
@OPENAPI_SPEC_FILE=$$(mktemp); \
|
||||
OPENAPI_DATA_DIR=$$(mktemp -d); \
|
||||
LNBITS_ADMIN_UI=False \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATA_FOLDER="$$OPENAPI_DATA_DIR" \
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL='[]' \
|
||||
LNBITS_EXTENSIONS_DEACTIVATE_ALL=true \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=false \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5003 \
|
||||
uv run lnbits &
|
||||
sleep 15
|
||||
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
|
||||
# kill -9 %1
|
||||
uv run lnbits & \
|
||||
OPENAPI_SERVER_PID=$$!; \
|
||||
trap 'kill "$$OPENAPI_SERVER_PID" 2>/dev/null || true; wait "$$OPENAPI_SERVER_PID" 2>/dev/null || true; rm -f "$$OPENAPI_SPEC_FILE"; rm -rf "$$OPENAPI_DATA_DIR"' EXIT; \
|
||||
OPENAPI_ATTEMPT=0; \
|
||||
while [ "$$OPENAPI_ATTEMPT" -lt 60 ]; do \
|
||||
if curl --fail --silent --max-time 2 --output "$$OPENAPI_SPEC_FILE" \
|
||||
http://127.0.0.1:5003/openapi.json; then \
|
||||
uv run openapi-spec-validator --errors=all "$$OPENAPI_SPEC_FILE"; \
|
||||
exit $$?; \
|
||||
fi; \
|
||||
if ! kill -0 "$$OPENAPI_SERVER_PID" 2>/dev/null; then \
|
||||
echo "LNbits exited before serving the OpenAPI schema." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
OPENAPI_ATTEMPT=$$((OPENAPI_ATTEMPT + 1)); \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "LNbits did not serve the OpenAPI schema within 60 seconds." >&2; \
|
||||
exit 1
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -1,12 +1,12 @@
|
|||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<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">
|
||||
<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">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
|
||||
 [![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" />
|
||||
[](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 src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
|
||||
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
|
||||
|
||||
## 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 src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
|
||||
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
|
||||
|
||||
## LNbits API
|
||||
|
||||
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
|
||||
|
||||
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
|
||||
<img alt="lnbits_api" src="docs/assets/api.jpg" />
|
||||
|
||||
## LNbits node manager
|
||||
|
||||
LNbits comes packaged with a light node management UI, to make running your node that much easier.
|
||||
|
||||
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
|
||||
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
|
||||
|
||||
## LNbits across all your devices
|
||||
## LNbits merchant tools
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
|
|
|
|||
36
SECURITY.md
Normal file
36
SECURITY.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are provided for the current released version of LNbits and the
|
||||
`dev` branch. Older releases and release candidates are not supported unless a
|
||||
maintainer explicitly states otherwise.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------------ | --------- |
|
||||
| Current release | Yes |
|
||||
| `dev` branch | Yes |
|
||||
| Older releases | No |
|
||||
| Release candidates | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities privately using [GitHub's private
|
||||
vulnerability reporting](https://github.com/lnbits/lnbits/security/advisories/new).
|
||||
Do not open a public issue, discussion, or pull request for a security
|
||||
vulnerability.
|
||||
|
||||
Include enough detail for maintainers to reproduce and assess the issue, such
|
||||
as the affected version or commit, configuration, steps to reproduce, impact,
|
||||
and any proof of concept. Do not include credentials, API keys, wallet data, or
|
||||
other sensitive information unless it is necessary and can be shared safely.
|
||||
|
||||
Maintainers will acknowledge the report, investigate it, and coordinate a fix
|
||||
and disclosure timeline with you. Please allow time for a fix to be prepared
|
||||
before publicly disclosing the vulnerability.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the LNbits core repository and LNbits extensions in the LNbits GitHub organisation. Vulnerabilities in third-party
|
||||
funding sources, dependencies or hosted LNbits instances may need to be reported to their respective maintainers or
|
||||
operators as well.
|
||||
BIN
docs/assets/api.jpg
Normal file
BIN
docs/assets/api.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
docs/assets/extensions.jpg
Normal file
BIN
docs/assets/extensions.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
BIN
docs/assets/header.jpg
Normal file
BIN
docs/assets/header.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/assets/lightning_node.jpg
Normal file
BIN
docs/assets/lightning_node.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/assets/merchants_small.webp
Normal file
BIN
docs/assets/merchants_small.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/assets/wallet.jpg
Normal file
BIN
docs/assets/wallet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
||||
|
|
@ -119,7 +118,7 @@ When set **at least one**, LNbits becomes private: only the listed users and Adm
|
|||
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
|
||||
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
- **[Install LNbits](./installation.md)** — Choose your preferred way to install LNbits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
||||
|
|
@ -72,8 +71,8 @@ Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use
|
|||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What’s available and how to enable/configure each.
|
||||
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
|
|
|
|||
|
|
@ -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) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# Basic installation
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ nav_order: 1
|
|||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
|
|
@ -285,10 +285,7 @@ but you can also set the env variables or pass command line arguments:
|
|||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
|
||||
# Once you have created a user, you can set as the super_user
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
> 
|
||||
|
|
|
|||
139
docs/guide/oidc-authentication.md
Normal file
139
docs/guide/oidc-authentication.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# 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,7 +14,6 @@ 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)
|
||||
|
||||
|
|
@ -43,7 +42,7 @@ nav_order: 1
|
|||
|
||||
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
|
||||
|
||||
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
|
||||
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep environment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
|
||||
|
||||
**Key SU capabilities**
|
||||
|
||||
|
|
@ -118,7 +117,7 @@ These are practical tips for running a safe and friendly instance.
|
|||
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
|
||||
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
|
||||
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
- **[Install LNbits](./installation.md)** — Choose your preferred way to install LNbits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
||||
|
|
@ -42,7 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
|
|||
| [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) | [Bark](#bark) | |
|
||||
| [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) | | |
|
||||
|
|
@ -128,6 +127,22 @@ 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`
|
||||
|
||||
## Bark
|
||||
|
||||
This connects LNbits to an external [barkd](https://second.tech/docs/barkd) REST daemon. Initialize the Bark wallet before starting LNbits using `bark create`, then run `barkd` separately, keep its data directory persistent, and set `BARK_API_TOKEN` to the auth token from `~/.bark/auth_token`.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BarkWallet`
|
||||
- `BARK_API_ENDPOINT`: `http://localhost:3000`
|
||||
- `BARK_API_TOKEN`: `auth_token`
|
||||
|
||||
Bark fee estimates can be higher than LNbits' default minimum routing fee reserve. If small outgoing payments fail with an error like `fee of 20000 msat exceeds limit of 5000 msat`, raise the minimum reserve fee under **Settings → Funding** or set:
|
||||
|
||||
```bash
|
||||
LNBITS_RESERVE_FEE_MIN=20000
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
|
@ -198,7 +213,7 @@ uv run lnbits-cli encrypt macaroon
|
|||
|
||||
## LNPay
|
||||
|
||||
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook).
|
||||
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: `https://mylnbits.example/wallet/webhook`.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
|
|
|
|||
212
lnbits/app.py
212
lnbits/app.py
|
|
@ -23,29 +23,44 @@ from lnbits.core.crud import (
|
|||
get_installed_extensions,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.extensions import create_installed_extension
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
dispatch_payment_notification,
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
check_pending_payments,
|
||||
fundingsource_invoice_producer,
|
||||
)
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
purge_audit_data,
|
||||
run_by_the_minute_tasks,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
notify_server_status,
|
||||
process_next_audit_entry,
|
||||
refresh_extension_cache,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.register import (
|
||||
register_wasm_extension,
|
||||
unregister_wasm_extension,
|
||||
)
|
||||
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
is_wasm_extension_dir,
|
||||
is_wasm_extension_id,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.llms_txt import create_llms_txt_route
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.logger import (
|
||||
configure_logger,
|
||||
|
|
@ -65,9 +80,10 @@ 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
|
||||
from .task_manager import task_manager
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
|
|
@ -101,6 +117,9 @@ async def startup(app: FastAPI):
|
|||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# register llms.txt endpoint for AI agents
|
||||
create_llms_txt_route(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
|
|
@ -128,7 +147,7 @@ async def shutdown():
|
|||
settings.lnbits_running = False
|
||||
|
||||
# shutdown event
|
||||
cancel_all_tasks()
|
||||
task_manager.cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
|
|
@ -161,6 +180,8 @@ def create_app() -> FastAPI:
|
|||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
|
||||
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
|
||||
core_app_extra.unregister_wasm_ext_routes = unregister_wasm_ext_routes(app)
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
|
|
@ -196,6 +217,9 @@ def create_app() -> FastAPI:
|
|||
|
||||
register_exception_handlers(app)
|
||||
|
||||
if settings.profiler:
|
||||
add_profiler_middleware(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
@ -289,7 +313,30 @@ async def build_all_installed_extensions_list( # noqa: C901
|
|||
installed_extensions = await get_installed_extensions()
|
||||
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
|
||||
|
||||
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
|
||||
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
for ext_dir in settings.wasm_extensions_dir.iterdir():
|
||||
try:
|
||||
if not ext_dir.is_dir() or not is_wasm_extension_dir(ext_dir):
|
||||
continue
|
||||
ext_id = ext_dir.name
|
||||
if ext_id in settings.lnbits_installed_extensions_ids:
|
||||
continue
|
||||
ext_info = InstallableExtension.from_wasm_ext_dir(ext_id)
|
||||
if not ext_info:
|
||||
continue
|
||||
|
||||
installed_extensions.append(ext_info)
|
||||
settings.lnbits_installed_extensions_ids.add(ext_id)
|
||||
await create_installed_extension(ext_info)
|
||||
current_version = await get_db_version(ext_id)
|
||||
await migrate_extension_database(ext_info, current_version)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
ext_dir_path = Path(settings.lnbits_extensions_path, "extensions")
|
||||
existing_ext_dirs = ext_dir_path.iterdir() if ext_dir_path.is_dir() else []
|
||||
for ext_dir in existing_ext_dirs:
|
||||
try:
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -344,14 +391,18 @@ async def build_all_installed_extensions_list( # noqa: C901
|
|||
|
||||
|
||||
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
if ext.is_wasm or ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{ext.zip_path!s}" not in zip_files:
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
archive_config = ext.load_archive_config()
|
||||
if archive_config.get("extension_type") == "wasm":
|
||||
ext.extract_wasm_archive()
|
||||
else:
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -373,7 +424,6 @@ def register_custom_extensions_path():
|
|||
upgrades_dir = settings.lnbits_extensions_upgrade_path
|
||||
shutil.rmtree(upgrades_dir, True)
|
||||
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(upgrades_dir))
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return
|
||||
|
|
@ -392,6 +442,7 @@ def register_custom_extensions_path():
|
|||
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(extensions_dir))
|
||||
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
|
|
@ -404,6 +455,20 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
|
|||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
|
||||
def register_new_wasm_ext_routes_fn(ext_id: str):
|
||||
register_wasm_extension(app, ext_id)
|
||||
|
||||
return register_new_wasm_ext_routes_fn
|
||||
|
||||
|
||||
def unregister_wasm_ext_routes(app: FastAPI) -> Callable:
|
||||
def unregister_wasm_ext_routes_fn(ext_id: str):
|
||||
unregister_wasm_extension(app, ext_id)
|
||||
|
||||
return unregister_wasm_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
|
|
@ -428,10 +493,54 @@ def register_ext_tasks(ext: Extension) -> None:
|
|||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
module_name = ext.module_name
|
||||
# Clear all cached sub-modules so a fresh import picks up new files from ext_dir.
|
||||
# A simple reload() would reuse cached sub-modules (e.g. views_api) and serve
|
||||
# stale code even after the extension files have been replaced on disk.
|
||||
stale = [
|
||||
k for k in sys.modules if k == module_name or k.startswith(f"{module_name}.")
|
||||
]
|
||||
for k in stale:
|
||||
del sys.modules[k]
|
||||
if stale:
|
||||
# Pydantic v1 keeps a global _FUNCS set of validator qualnames to detect
|
||||
# duplicates. Clear the extension's entries so reimport doesn't raise
|
||||
# "duplicate validator" errors for validators with the same qualname.
|
||||
try:
|
||||
import pydantic.class_validators as _pydantic_cv
|
||||
|
||||
_pydantic_cv._FUNCS = {
|
||||
f for f in _pydantic_cv._FUNCS if not f.startswith(f"{module_name}.")
|
||||
}
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
ext_module = importlib.import_module(module_name)
|
||||
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
|
||||
ext_redirects = (
|
||||
getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
else []
|
||||
)
|
||||
|
||||
settings.activate_extension_paths(ext.code, ext_redirects)
|
||||
|
||||
# Remove existing routes for this extension before re-registering so that
|
||||
# an upgraded extension replaces the old one at the same paths (no prefix).
|
||||
ext_prefix = f"/{ext.code}"
|
||||
app.router.routes = [
|
||||
r
|
||||
for r in app.router.routes
|
||||
if not (
|
||||
getattr(r, "path", "") == ext_prefix
|
||||
or getattr(r, "path", "").startswith(f"{ext_prefix}/")
|
||||
)
|
||||
]
|
||||
# Invalidate FastAPI's cached OpenAPI schema so the next /openapi.json
|
||||
# request reflects the updated routes.
|
||||
app.openapi_schema = None
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||
for s in ext_statics:
|
||||
|
|
@ -440,49 +549,66 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||
)
|
||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||
|
||||
ext_redirects = (
|
||||
getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
else []
|
||||
)
|
||||
|
||||
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
|
||||
|
||||
logger.trace(f"Adding route for extension {ext_module}.")
|
||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
app.include_router(router=ext_route)
|
||||
|
||||
|
||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
if is_wasm_extension_id(ext.code):
|
||||
register_wasm_extension(app, ext.code)
|
||||
continue
|
||||
register_ext_routes(app, ext)
|
||||
register_ext_tasks(ext)
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
await update_installed_extension_state(ext_id=ext.code, active=False)
|
||||
|
||||
|
||||
def register_async_tasks() -> None:
|
||||
task_manager.init()
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
# listen to all incoming payments and dispatch payment notifications
|
||||
# note: should be the first in task list for a bit quicker notifications
|
||||
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
|
||||
|
||||
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)
|
||||
# periodic tasks
|
||||
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
|
||||
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
|
||||
task_manager.create_permanent_task(
|
||||
check_pending_payments,
|
||||
interval=settings.lnbits_funding_source_pending_interval_seconds,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
collect_exchange_rates_data,
|
||||
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
|
||||
)
|
||||
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
|
||||
task_manager.create_permanent_task(
|
||||
check_server_balance_against_node,
|
||||
interval=60 * settings.lnbits_watchdog_interval_minutes,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
notify_server_status,
|
||||
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
|
||||
)
|
||||
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
# permanent tasks run in a loop, will be restarted if they fail
|
||||
task_manager.create_permanent_task(fundingsource_invoice_producer)
|
||||
task_manager.create_permanent_task(process_next_notification)
|
||||
task_manager.create_permanent_task(process_next_audit_entry)
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
async def dispatch_extension_invoice_paid(payment) -> None:
|
||||
await dispatch_wasm_invoice_paid(payment)
|
||||
|
||||
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
create_permanent_task(server_log_task)
|
||||
task_manager.create_permanent_task(
|
||||
server_log_task, name="server_websocket_logger"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
|
|
@ -377,10 +376,6 @@ async def extensions_update( # noqa: C901
|
|||
if not await _can_run_operation(url):
|
||||
return
|
||||
|
||||
upgrades_dir = settings.lnbits_extensions_upgrade_path
|
||||
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(upgrades_dir))
|
||||
|
||||
if extension:
|
||||
await update_extension(extension, repo_index, source_repo, url, admin_user)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .views.api import api_router
|
|||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.blockexplorer_api import blockexplorer_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.extensions_builder_api import extension_builder_router
|
||||
|
|
@ -20,7 +21,7 @@ from .views.tinyurl_api import tinyurl_router
|
|||
from .views.user_api import users_router
|
||||
from .views.wallet_api import wallet_router
|
||||
from .views.webpush_api import webpush_router
|
||||
from .views.websocket_api import websocket_router
|
||||
from .views.websocket_api import extension_websocket_router, websocket_router
|
||||
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
|
@ -33,6 +34,7 @@ def init_core_routers(app: FastAPI):
|
|||
app.include_router(admin_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(extension_websocket_router)
|
||||
app.include_router(extension_builder_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
|
|
@ -48,6 +50,7 @@ def init_core_routers(app: FastAPI):
|
|||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
app.include_router(blockexplorer_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
|
|
|||
|
|
@ -12,18 +12,19 @@ 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,
|
||||
update_installed_extension,
|
||||
update_installed_extension_state,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_user_extension,
|
||||
)
|
||||
from .payments import (
|
||||
DateTrunc,
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_expired_invoices,
|
||||
delete_wallet_payment,
|
||||
get_latest_payments_by_extension,
|
||||
get_payment,
|
||||
|
|
@ -58,6 +59,7 @@ 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,
|
||||
|
|
@ -100,7 +102,6 @@ __all__ = [
|
|||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
|
|
@ -117,11 +118,13 @@ __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",
|
||||
|
|
@ -152,6 +155,7 @@ __all__ = [
|
|||
"update_admin_settings",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
"update_migration_version",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
UserExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
from lnbits.db import Connection, Database
|
||||
|
||||
|
|
@ -11,6 +16,11 @@ async def create_installed_extension(
|
|||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension(
|
||||
|
|
@ -18,6 +28,11 @@ async def update_installed_extension(
|
|||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
|
|
@ -31,6 +46,33 @@ async def update_installed_extension_state(
|
|||
)
|
||||
|
||||
|
||||
async def update_installed_extension_wasm_runtime_limits(
|
||||
*, ext_id: str, limits: dict, conn: Connection | None = None
|
||||
) -> None:
|
||||
if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn):
|
||||
return
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE installed_extensions
|
||||
SET wasm_runtime_limits = :limits
|
||||
WHERE id = :id
|
||||
""",
|
||||
{"id": ext_id, "limits": json.dumps(limits)},
|
||||
)
|
||||
|
||||
|
||||
async def _has_installed_extension_wasm_runtime_limits_column(
|
||||
conn: Connection | None = None,
|
||||
) -> bool:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT version FROM dbversions WHERE db = 'core'"
|
||||
)
|
||||
if not row:
|
||||
return False
|
||||
return int(row["version"] or 0) >= 48
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
|
|
@ -90,6 +132,13 @@ 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:
|
||||
|
|
@ -137,3 +186,158 @@ async def get_user_active_extensions_ids(
|
|||
UserExtension,
|
||||
)
|
||||
return [ext.extension for ext in exts]
|
||||
|
||||
|
||||
async def create_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def update_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def get_wasm_invocation(
|
||||
invocation_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocation | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM wasm_invocations WHERE id = :id",
|
||||
{"id": invocation_id},
|
||||
model=WasmInvocation,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocations(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
where: list[str] = []
|
||||
values: dict = {
|
||||
"limit": max(1, min(limit, 500)),
|
||||
"offset": max(offset, 0),
|
||||
}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
values["status"] = status
|
||||
|
||||
query = "SELECT * FROM wasm_invocations"
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset"
|
||||
|
||||
return await (conn or db).fetchall(query, values, model=WasmInvocation)
|
||||
|
||||
|
||||
async def get_running_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
return await get_wasm_invocations(status="running", conn=conn)
|
||||
|
||||
|
||||
async def get_wasm_invocation_stats(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
since: datetime | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocationStats:
|
||||
database = conn or db
|
||||
where: list[str] = []
|
||||
values: dict = {}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if since:
|
||||
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
|
||||
values["since"] = since
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)
|
||||
AS running,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
|
||||
AS completed,
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)
|
||||
AS failed,
|
||||
COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0)
|
||||
AS stopped,
|
||||
COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0)
|
||||
AS timeout,
|
||||
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
||||
COALESCE(MAX(duration_ms), 0) AS max_duration_ms,
|
||||
COALESCE(SUM(host_call_count), 0) AS host_call_count,
|
||||
COALESCE(SUM(http_call_count), 0) AS http_call_count,
|
||||
COALESCE(SUM(storage_call_count), 0) AS storage_call_count,
|
||||
COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count
|
||||
FROM wasm_invocations
|
||||
"""
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
|
||||
row: dict | None = await (conn or db).fetchone(query, values)
|
||||
if not row:
|
||||
return WasmInvocationStats()
|
||||
|
||||
return WasmInvocationStats(
|
||||
total=int(row["total"] or 0),
|
||||
running=int(row["running"] or 0),
|
||||
completed=int(row["completed"] or 0),
|
||||
failed=int(row["failed"] or 0),
|
||||
stopped=int(row["stopped"] or 0),
|
||||
timeout=int(row["timeout"] or 0),
|
||||
avg_duration_ms=float(row["avg_duration_ms"] or 0),
|
||||
max_duration_ms=int(row["max_duration_ms"] or 0),
|
||||
host_call_count=int(row["host_call_count"] or 0),
|
||||
http_call_count=int(row["http_call_count"] or 0),
|
||||
storage_call_count=int(row["storage_call_count"] or 0),
|
||||
wallet_call_count=int(row["wallet_call_count"] or 0),
|
||||
)
|
||||
|
||||
|
||||
async def delete_old_wasm_invocations(
|
||||
retention_days: int,
|
||||
conn: Connection | None = None,
|
||||
) -> int:
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
database = conn or db
|
||||
result = await database.execute(
|
||||
f"""
|
||||
DELETE FROM wasm_invocations
|
||||
WHERE status != 'running'
|
||||
AND started_at < {database.timestamp_placeholder("cutoff")}
|
||||
""", # noqa: S608
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def mark_stale_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
database = conn or db
|
||||
await database.execute(
|
||||
f"""
|
||||
UPDATE wasm_invocations
|
||||
SET status = 'abandoned',
|
||||
finished_at = {database.timestamp_placeholder("finished_at")},
|
||||
stop_reason = 'Server restarted before invocation finished.'
|
||||
WHERE status = 'running'
|
||||
""", # noqa: S608
|
||||
{"finished_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from ..models import (
|
|||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentsStatusCount,
|
||||
PaymentTotalBreakdown,
|
||||
PaymentWalletStats,
|
||||
)
|
||||
|
||||
|
|
@ -149,14 +150,12 @@ 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:
|
||||
|
|
@ -240,32 +239,6 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
|||
)
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
|
|
@ -292,8 +265,10 @@ async def create_payment(
|
|||
webhook=data.webhook,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extension=data.extension,
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
|
|
@ -307,7 +282,7 @@ async def update_payment_checking_id(
|
|||
await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
|
||||
WHERE checking_id = :old_id
|
||||
""", # noqa: S608
|
||||
{
|
||||
|
|
@ -322,13 +297,15 @@ async def update_payment(
|
|||
payment: Payment,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
) -> Payment:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
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(
|
||||
|
|
@ -346,14 +323,12 @@ 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:
|
||||
|
|
@ -402,7 +377,6 @@ async def get_payment_count_stats(
|
|||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
|
|
@ -430,12 +404,46 @@ async def get_payment_count_stats(
|
|||
return data
|
||||
|
||||
|
||||
async def get_wallet_payment_total_breakdown(
|
||||
wallet_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentTotalBreakdown]:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return []
|
||||
|
||||
values = {"wallet_id": wallet.source_wallet_id}
|
||||
data = await (conn or db).fetchall(
|
||||
query=f"""
|
||||
SELECT tag,
|
||||
CASE
|
||||
WHEN fiat_provider IS NOT NULL
|
||||
THEN true
|
||||
ELSE false
|
||||
END AS is_fiat,
|
||||
COUNT(*) AS payments_count,
|
||||
SUM(amount - ABS(fee)) AS total
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet_id
|
||||
AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
GROUP BY tag, is_fiat
|
||||
ORDER BY tag
|
||||
""", # noqa: S608
|
||||
values=values,
|
||||
model=PaymentTotalBreakdown,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def get_daily_stats(
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
|
@ -485,7 +493,6 @@ async def get_wallets_stats(
|
|||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
|
|||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
FundingSourcesSettings,
|
||||
SettingsField,
|
||||
SuperSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
RESET_PRESERVED_SETTINGS = (
|
||||
"lnbits_webpush_pubkey",
|
||||
"lnbits_webpush_privkey",
|
||||
*FundingSourcesSettings.__fields__,
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> SuperSettings | None:
|
||||
data = await get_settings_by_tag("core")
|
||||
|
|
@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
|
|||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
core_settings = await get_settings_by_tag("core") or {}
|
||||
super_user = await get_settings_field("super_user")
|
||||
await delete_admin_settings()
|
||||
if super_user:
|
||||
await set_settings_field("super_user", super_user.value)
|
||||
for field in RESET_PRESERVED_SETTINGS:
|
||||
if field in core_settings:
|
||||
await set_settings_field(field, core_settings[field])
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ 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_wallets
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallets,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
|
@ -32,6 +37,13 @@ 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)
|
||||
|
|
@ -52,17 +64,22 @@ async def get_accounts(
|
|||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
filters = filters or Filters()
|
||||
|
||||
# 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"]
|
||||
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")
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from uuid import uuid4
|
|||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.helpers import generate_ln_address
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ async def create_wallet(
|
|||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
if settings.ln_address_creation_allowed and wallet.is_lightning_wallet:
|
||||
wallet.lightning_address = await generate_lightning_address_local_part(conn)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
|
@ -123,11 +126,21 @@ async def get_standalone_wallet(
|
|||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchone(
|
||||
wallet = await (conn or db).fetchone(
|
||||
query,
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
if deleted is True:
|
||||
return wallet
|
||||
|
||||
if not wallet.lightning_address and settings.ln_address_creation_allowed:
|
||||
wallet.lightning_address = await generate_lightning_address_local_part(conn)
|
||||
await update_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
|
|
@ -220,6 +233,30 @@ async def get_wallets_count():
|
|||
return row.get("count", 0)
|
||||
|
||||
|
||||
async def generate_lightning_address_local_part(
|
||||
conn: Connection | None = None,
|
||||
) -> str:
|
||||
for _ in range(100):
|
||||
local_part = generate_ln_address()
|
||||
if await get_wallet_id_by_ln_address(local_part, conn):
|
||||
continue
|
||||
return local_part
|
||||
raise ValueError("Could not generate a unique wallet lightning address.")
|
||||
|
||||
|
||||
async def get_wallet_id_by_ln_address(
|
||||
local_part: str, conn: Connection | None = None
|
||||
) -> str | None:
|
||||
row: dict = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id FROM wallets
|
||||
WHERE lightning_address = :lightning_address
|
||||
""",
|
||||
{"lightning_address": local_part.lower()},
|
||||
)
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from lnbits.core.crud import (
|
|||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
|
@ -22,7 +24,16 @@ from lnbits.settings import settings
|
|||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
if is_wasm_extension_id(ext.id):
|
||||
await migrate_wasm_extension_database(ext, current_version)
|
||||
return
|
||||
else:
|
||||
await migrate_py_extension_database(ext, current_version)
|
||||
|
||||
|
||||
async def migrate_py_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
|
|
|
|||
|
|
@ -10,31 +10,26 @@ 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,
|
||||
|
|
@ -42,10 +37,8 @@ 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,
|
||||
|
|
@ -53,10 +46,8 @@ 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,
|
||||
|
|
@ -67,11 +58,9 @@ 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
|
||||
|
|
@ -85,8 +74,7 @@ async def m001_initial(db: Connection):
|
|||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
|
|
@ -149,8 +137,7 @@ 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
|
||||
|
|
@ -164,8 +151,7 @@ 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):
|
||||
|
|
@ -174,8 +160,7 @@ 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,
|
||||
|
|
@ -183,19 +168,16 @@ 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):
|
||||
|
|
@ -262,19 +244,16 @@ 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,
|
||||
|
|
@ -282,13 +261,11 @@ 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,
|
||||
|
|
@ -299,8 +276,7 @@ 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):
|
||||
|
|
@ -309,23 +285,19 @@ 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):
|
||||
|
|
@ -345,15 +317,13 @@ 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:
|
||||
|
|
@ -386,8 +356,7 @@ 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,
|
||||
|
|
@ -396,8 +365,7 @@ 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):
|
||||
|
|
@ -484,8 +452,7 @@ 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
|
||||
|
|
@ -495,8 +462,7 @@ 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):
|
||||
|
|
@ -505,8 +471,7 @@ 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
|
||||
|
|
@ -516,8 +481,7 @@ 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):
|
||||
|
|
@ -536,8 +500,7 @@ 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
|
||||
|
|
@ -549,8 +512,7 @@ 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):
|
||||
|
|
@ -581,8 +543,7 @@ 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
|
||||
|
|
@ -594,8 +555,7 @@ 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):
|
||||
|
|
@ -658,8 +618,7 @@ 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,
|
||||
|
|
@ -667,8 +626,7 @@ async def m028_update_settings(db: Connection):
|
|||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
|
|
@ -691,8 +649,7 @@ 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,
|
||||
|
|
@ -706,16 +663,13 @@ 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):
|
||||
|
|
@ -738,32 +692,25 @@ 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,
|
||||
|
|
@ -776,16 +723,13 @@ async def m037_create_assets_table(db: Connection):
|
|||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m039_index_payments(db: Connection):
|
||||
|
|
@ -804,11 +748,9 @@ 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):
|
||||
|
|
@ -825,11 +767,9 @@ 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):
|
||||
|
|
@ -843,11 +783,9 @@ 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):
|
||||
|
|
@ -864,3 +802,103 @@ async def m044_add_activated_to_accounts(db: Connection):
|
|||
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);
|
||||
""")
|
||||
|
||||
|
||||
async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds granted permissions to installed extensions.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
|
||||
async def m047_create_wasm_invocations_table(db: Connection):
|
||||
"""
|
||||
Tracks WASM extension invocations for runtime monitoring and controls.
|
||||
"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS wasm_invocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
extension_id TEXT NOT NULL,
|
||||
export_name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
finished_at TIMESTAMP,
|
||||
duration_ms INT,
|
||||
user_id TEXT,
|
||||
wallet_id TEXT,
|
||||
request_id TEXT,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
event_type TEXT,
|
||||
payment_hash TEXT,
|
||||
checking_id TEXT,
|
||||
memory_peak_bytes INT,
|
||||
request_bytes INT,
|
||||
response_bytes INT,
|
||||
host_call_count INT NOT NULL DEFAULT 0,
|
||||
http_call_count INT NOT NULL DEFAULT 0,
|
||||
storage_call_count INT NOT NULL DEFAULT 0,
|
||||
wallet_call_count INT NOT NULL DEFAULT 0,
|
||||
error_type TEXT,
|
||||
error_message TEXT,
|
||||
stop_reason TEXT,
|
||||
"context" TEXT NOT NULL DEFAULT '{{}}'
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started
|
||||
ON wasm_invocations (extension_id, started_at);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status
|
||||
ON wasm_invocations (status);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started
|
||||
ON wasm_invocations (started_at);
|
||||
""")
|
||||
|
||||
|
||||
async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds per-extension WASM runtime limit overrides.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions "
|
||||
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
|
||||
)
|
||||
|
||||
|
||||
async def m049_add_permissions_to_user_extensions(db: Connection):
|
||||
"""
|
||||
Adds user-level extension permission grants.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
|
||||
|
||||
|
||||
async def m050_add_lightning_address_to_wallets(db: Connection):
|
||||
"""
|
||||
Adds a LUD-16 lightning address local-part to wallets.
|
||||
"""
|
||||
await db.execute("ALTER TABLE wallets ADD COLUMN lightning_address TEXT")
|
||||
logger.debug("Creating index idx_wallets_lightning_address...")
|
||||
await db.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallets_lightning_address
|
||||
ON wallets (lightning_address);
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ from .payments import (
|
|||
PaymentHistoryPoint,
|
||||
PaymentsStatusCount,
|
||||
PaymentState,
|
||||
PaymentTotalBreakdown,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
|
|
@ -82,6 +84,7 @@ __all__ = [
|
|||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
"PaymentTotalBreakdown",
|
||||
"PaymentWalletStats",
|
||||
"PaymentsStatusCount",
|
||||
"RegisterUser",
|
||||
|
|
@ -90,6 +93,7 @@ __all__ = [
|
|||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdatePaymentExtra",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ import json
|
|||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from pathlib import Path
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StrictStr
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
|
|
@ -21,9 +24,14 @@ from lnbits.helpers import (
|
|||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
class ExtensionArchiveValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
|
@ -43,6 +51,7 @@ class ExplicitRelease(BaseModel):
|
|||
details_link: str | None
|
||||
paid_features: str | None
|
||||
pay_link: str | None
|
||||
extension_type: str | None = None
|
||||
|
||||
def is_version_compatible(self):
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
|
|
@ -55,9 +64,15 @@ class GitHubRelease(BaseModel):
|
|||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
featured: list[str] = []
|
||||
categories: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class ExtensionManifestType(str, Enum):
|
||||
PYTHON = "python"
|
||||
WASM = "wasm"
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
|
|
@ -76,6 +91,23 @@ class GitHubRepo(BaseModel):
|
|||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: StrictStr
|
||||
description: StrictStr | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
ExtensionPermission.parse_obj(permission)
|
||||
for permission in config_json.get("permissions") or []
|
||||
if isinstance(permission, dict) and permission.get("id")
|
||||
]
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
|
|
@ -83,10 +115,18 @@ class ExtensionConfig(BaseModel):
|
|||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
extension_type: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_config(cls, url: str) -> ExtensionConfig:
|
||||
error_msg = "Cannot fetch extension release config"
|
||||
config = await extension_metadata_get(url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
@classmethod
|
||||
async def fetch_github_release_config(
|
||||
cls, org: str, repo: str, tag_name: str
|
||||
|
|
@ -94,7 +134,7 @@ class ExtensionConfig(BaseModel):
|
|||
config_url = (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
error_msg = "Cannot fetch GitHub extension config"
|
||||
error_msg = "Cannot fetch extension release config"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
|
|
@ -117,11 +157,87 @@ class UserExtensionInfo(BaseModel):
|
|||
payment_hash_to_enable: str | None = None
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
|
||||
OWN_WALLETS_ONLY = "own_wallets_only"
|
||||
EXTERNAL_ALLOWED = "external_allowed"
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
|
||||
return ExtensionBackgroundPaymentGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
max_amount=self.max_amount,
|
||||
destination_policy=self.destination_policy,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
def to_grant(
|
||||
self, grant_id: str | None = None
|
||||
) -> ExtensionWalletPaymentsWatchGrant:
|
||||
return ExtensionWalletPaymentsWatchGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckItem(BaseModel):
|
||||
id: StrictStr
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckRequest(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResult(BaseModel):
|
||||
id: StrictStr
|
||||
approved: bool
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResponse(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionsResponse(BaseModel):
|
||||
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
|
||||
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionsUpdate(BaseModel):
|
||||
permissions: list[ExtensionPermission] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
user: str
|
||||
extension: str
|
||||
active: bool
|
||||
extra: UserExtensionInfo | None = None
|
||||
permissions: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
|
|
@ -143,36 +259,86 @@ class UserExtension(BaseModel):
|
|||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_wasm: bool = False
|
||||
name: str | None = None
|
||||
short_description: str | None = None
|
||||
tile: str | None = None
|
||||
upgrade_hash: str | None = ""
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
if self.is_upgrade_extension:
|
||||
return f"{self.code}-{self.upgrade_hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.code}"
|
||||
return self.code
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_wasm=ext_info.is_wasm,
|
||||
name=ext_info.name,
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
tile=_extension_tile(ext_info),
|
||||
)
|
||||
|
||||
|
||||
class WasmInvocation(BaseModel):
|
||||
id: str
|
||||
extension_id: str
|
||||
export_name: str
|
||||
trigger_type: str = "unknown"
|
||||
status: str = "running"
|
||||
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int | None = None
|
||||
user_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
event_type: str | None = None
|
||||
payment_hash: str | None = None
|
||||
checking_id: str | None = None
|
||||
memory_peak_bytes: int | None = None
|
||||
request_bytes: int | None = None
|
||||
response_bytes: int | None = None
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
stop_reason: str | None = None
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmInvocationStats(BaseModel):
|
||||
total: int = 0
|
||||
running: int = 0
|
||||
completed: int = 0
|
||||
failed: int = 0
|
||||
stopped: int = 0
|
||||
timeout: int = 0
|
||||
avg_duration_ms: float = 0
|
||||
max_duration_ms: int = 0
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
|
||||
|
||||
class WasmRuntimeLimitsUpdate(BaseModel):
|
||||
limits: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmRuntimeLimitsInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
active: bool | None = False
|
||||
wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
|
|
@ -189,6 +355,9 @@ class ExtensionRelease(BaseModel):
|
|||
repo: str | None = None
|
||||
icon: str | None = None
|
||||
details_link: str | None = None
|
||||
extension_type: str | None = None
|
||||
manifest_type: ExtensionManifestType | None = Field(default=None, exclude=True)
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
paid_features: str | None = None
|
||||
pay_link: str | None = None
|
||||
|
|
@ -196,6 +365,14 @@ class ExtensionRelease(BaseModel):
|
|||
paid_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
|
||||
def apply_config(self, config: ExtensionConfig) -> None:
|
||||
self.min_lnbits_version = config.min_lnbits_version
|
||||
self.max_lnbits_version = config.max_lnbits_version
|
||||
self.is_version_compatible = config.is_version_compatible()
|
||||
self.warning = config.warning
|
||||
self.extension_type = config.extension_type
|
||||
self.permissions = config.permissions
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
if not self.pay_link:
|
||||
|
|
@ -259,6 +436,7 @@ class ExtensionRelease(BaseModel):
|
|||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
extension_type=e.extension_type,
|
||||
pay_link=e.pay_link,
|
||||
paid_features=e.paid_features,
|
||||
repo=e.repo,
|
||||
|
|
@ -286,10 +464,7 @@ class ExtensionRelease(BaseModel):
|
|||
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.apply_config(config)
|
||||
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
|
||||
|
||||
return extension_releases
|
||||
|
|
@ -308,7 +483,6 @@ class ExtensionRelease(BaseModel):
|
|||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
|
|
@ -333,6 +507,7 @@ class ExtensionMeta(BaseModel):
|
|||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
categories: list[str] = []
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
|
|
@ -347,6 +522,8 @@ class InstallableExtension(BaseModel):
|
|||
icon: str | None = None
|
||||
stars: int = 0
|
||||
meta: ExtensionMeta | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True)
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
|
|
@ -368,15 +545,16 @@ class InstallableExtension(BaseModel):
|
|||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def wasm_ext_dir(self) -> Path:
|
||||
return Path(settings.wasm_extensions_dir, self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
if self.ext_upgrade_dir.is_dir():
|
||||
return f"{self.id}-{self.hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
return self.id
|
||||
|
|
@ -399,6 +577,18 @@ class InstallableExtension(BaseModel):
|
|||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
@property
|
||||
def is_wasm(self) -> bool:
|
||||
config_path = Path(self.wasm_ext_dir, "config.json")
|
||||
if not config_path.is_file():
|
||||
return False
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
except Exception:
|
||||
return False
|
||||
return config_json.get("extension_type") == "wasm"
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
|
|
@ -431,6 +621,57 @@ class InstallableExtension(BaseModel):
|
|||
os.remove(ext_zip_file)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def load_archive_config(self) -> dict[str, Any]:
|
||||
if not self.zip_path.is_file():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
config_name = _archive_config_name(archive.namelist())
|
||||
if not config_name:
|
||||
return {}
|
||||
with archive.open(config_name) as config_file:
|
||||
config = json.load(config_file)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc
|
||||
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
def validate_archive(self, config: Mapping[str, Any]) -> None:
|
||||
release = self.meta.installed_release if self.meta else None
|
||||
manifest_type = release.manifest_type if release else None
|
||||
is_wasm = config.get("extension_type") == "wasm"
|
||||
|
||||
if manifest_type == ExtensionManifestType.PYTHON and is_wasm:
|
||||
raise ExtensionArchiveValidationError(
|
||||
f"Python extension manifest cannot install WASM extension '{self.id}'."
|
||||
)
|
||||
if manifest_type == ExtensionManifestType.WASM and not is_wasm:
|
||||
raise ExtensionArchiveValidationError(
|
||||
"WASM extension manifest requires extension_type 'wasm' "
|
||||
f"for extension '{self.id}'."
|
||||
)
|
||||
if not is_wasm:
|
||||
return
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
python_file = next(
|
||||
(
|
||||
item.filename
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir()
|
||||
and PurePosixPath(item.filename).suffix.lower()
|
||||
in {".py", ".pyc", ".pyo", ".so", ".pyd"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if python_file:
|
||||
raise ExtensionArchiveValidationError(
|
||||
f"WASM extension '{self.id}' contains forbidden Python file "
|
||||
f"'{python_file}'."
|
||||
)
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -467,6 +708,38 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||
|
||||
def extract_wasm_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
extracted_dir = Path(tmp_dir, generated_dir_name)
|
||||
|
||||
with open(Path(extracted_dir, "config.json"), "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
|
||||
if (
|
||||
self.meta
|
||||
and self.meta.installed_release
|
||||
and self.meta.installed_release.is_github_release
|
||||
and config_json.get("tile")
|
||||
):
|
||||
self.icon = icon_to_github_url(
|
||||
self.meta.installed_release.source_repo, config_json.get("tile")
|
||||
)
|
||||
|
||||
shutil.rmtree(self.wasm_ext_dir, True)
|
||||
shutil.copytree(extracted_dir, self.wasm_ext_dir)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||
|
||||
def clean_extension_files(self):
|
||||
|
|
@ -479,6 +752,12 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def clean_wasm_extension_files(self):
|
||||
if self.zip_path.is_file():
|
||||
os.remove(self.zip_path)
|
||||
|
||||
shutil.rmtree(self.wasm_ext_dir, True)
|
||||
|
||||
def check_release_updates(self, release: ExtensionRelease | None):
|
||||
self._check_latest_version(release)
|
||||
self._check_payment_link(release)
|
||||
|
|
@ -609,6 +888,42 @@ class InstallableExtension(BaseModel):
|
|||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
version=version,
|
||||
archive=f"{conf_path}",
|
||||
source_repo=f"{conf_path}",
|
||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||
max_lnbits_version=config_json.get("max_lnbits_version"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_wasm_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
|
||||
try:
|
||||
conf_path = Path(settings.wasm_extensions_dir, ext_id, "config.json")
|
||||
if not conf_path.is_file():
|
||||
return None
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
version = config_json.get("version", "0.0")
|
||||
|
||||
return InstallableExtension(
|
||||
id=ext_id,
|
||||
name=config_json.get("name", ext_id),
|
||||
active=True,
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
|
|
@ -641,7 +956,10 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
if cache_value.older_than(10 * 60) or post_refresh_cache:
|
||||
# refresh cache in background if older than 10 minutes or requested
|
||||
create_task(cls._refresh_installable_extensions_cache())
|
||||
task_manager.create_task(
|
||||
cls._refresh_installable_extensions_cache(),
|
||||
"refresh_installable_extensions_cache",
|
||||
)
|
||||
|
||||
extension_list = cache_value.value # type: ignore
|
||||
return extension_list
|
||||
|
|
@ -663,7 +981,7 @@ class InstallableExtension(BaseModel):
|
|||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
for url, manifest_type in _extension_manifest_sources():
|
||||
try:
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
|
||||
|
|
@ -671,6 +989,8 @@ class InstallableExtension(BaseModel):
|
|||
ext = await InstallableExtension.from_github_release(r)
|
||||
if not ext:
|
||||
continue
|
||||
if ext.meta and ext.meta.latest_release:
|
||||
ext.meta.latest_release.manifest_type = manifest_type
|
||||
existing_ext = next(
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
|
|
@ -680,11 +1000,17 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
|
||||
for e in manifest.extensions:
|
||||
release = ExtensionRelease.from_explicit_release(url, e)
|
||||
release.manifest_type = manifest_type
|
||||
existing_ext = next(
|
||||
(ee for ee in extension_list if ee.id == e.id), None
|
||||
)
|
||||
|
|
@ -695,6 +1021,11 @@ class InstallableExtension(BaseModel):
|
|||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
except Exception as e:
|
||||
|
|
@ -706,11 +1037,9 @@ class InstallableExtension(BaseModel):
|
|||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||
extension_releases: list[ExtensionRelease] = []
|
||||
all_manifests = [
|
||||
*settings.lnbits_extensions_manifests,
|
||||
settings.lnbits_extensions_builder_manifest_url,
|
||||
]
|
||||
for url in all_manifests:
|
||||
for url, manifest_type in _extension_manifest_sources(
|
||||
include_builder=True, deduplicate=False
|
||||
):
|
||||
try:
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
|
|
@ -719,12 +1048,23 @@ class InstallableExtension(BaseModel):
|
|||
repo_releases = await ExtensionRelease.get_github_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
for release in repo_releases:
|
||||
release.manifest_type = manifest_type
|
||||
extension_releases += repo_releases
|
||||
|
||||
for e in manifest.extensions:
|
||||
if e.id != ext_id:
|
||||
continue
|
||||
explicit_release = ExtensionRelease.from_explicit_release(url, e)
|
||||
if (
|
||||
explicit_release.extension_type == "wasm"
|
||||
and explicit_release.details_link
|
||||
):
|
||||
config = await ExtensionConfig.fetch_release_config(
|
||||
explicit_release.details_link
|
||||
)
|
||||
explicit_release.apply_config(config)
|
||||
explicit_release.manifest_type = manifest_type
|
||||
await explicit_release.check_payment_requirements()
|
||||
extension_releases.append(explicit_release)
|
||||
|
||||
|
|
@ -778,7 +1118,7 @@ class InstallableExtension(BaseModel):
|
|||
@classmethod
|
||||
async def fetch_manifest(cls, url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await github_api_get(url, error_msg)
|
||||
manifest = await extension_metadata_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
|
|
@ -789,6 +1129,7 @@ class CreateExtension(BaseModel):
|
|||
version: str
|
||||
cost_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
|
|
@ -823,11 +1164,36 @@ class ExtensionReview(BaseModel):
|
|||
comment: str | None = Field(default=None)
|
||||
|
||||
|
||||
async def extension_metadata_get(url: str, error_msg: str | None) -> Any:
|
||||
try:
|
||||
parsed_url = httpx.URL(url)
|
||||
except Exception as exc:
|
||||
raise ValueError("Invalid extension metadata URL") from exc
|
||||
if parsed_url.userinfo:
|
||||
raise ValueError("Extension metadata URLs must not contain credentials")
|
||||
if _is_github_token_url(url):
|
||||
return await github_api_get(url, error_msg)
|
||||
return await unauthenticated_json_get(url, error_msg)
|
||||
|
||||
|
||||
async def unauthenticated_json_get(url: str, error_msg: str | None) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=False) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: str | None) -> Any:
|
||||
if not _is_github_token_url(url):
|
||||
raise ValueError("Refusing GitHub authentication for an untrusted origin")
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=False) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
|
|
@ -841,3 +1207,65 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
|
|||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
def wasm_extension_icon_url(ext_id: str) -> str:
|
||||
return f"/ext-assets/{ext_id}/assets/icon.png"
|
||||
|
||||
|
||||
def _extension_tile(ext_info: InstallableExtension) -> str | None:
|
||||
if ext_info.is_wasm:
|
||||
return wasm_extension_icon_url(ext_info.id)
|
||||
return ext_info.icon
|
||||
|
||||
|
||||
def _archive_config_name(names: list[str]) -> str | None:
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if len(path.parts) == 2 and path.name == "config.json":
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _extension_manifest_sources(
|
||||
*, include_builder: bool = False, deduplicate: bool = True
|
||||
) -> list[tuple[str, ExtensionManifestType]]:
|
||||
sources = [
|
||||
*(
|
||||
(url, ExtensionManifestType.PYTHON)
|
||||
for url in settings.lnbits_extensions_manifests
|
||||
),
|
||||
*(
|
||||
(url, ExtensionManifestType.WASM)
|
||||
for url in settings.lnbits_wasm_extensions_manifests
|
||||
),
|
||||
]
|
||||
if include_builder:
|
||||
sources.append(
|
||||
(
|
||||
settings.lnbits_extensions_builder_manifest_url,
|
||||
ExtensionManifestType.PYTHON,
|
||||
)
|
||||
)
|
||||
if not deduplicate:
|
||||
return sources
|
||||
unique_sources: dict[str, ExtensionManifestType] = {}
|
||||
for url, manifest_type in sources:
|
||||
unique_sources.setdefault(url, manifest_type)
|
||||
return list(unique_sources.items())
|
||||
|
||||
|
||||
_GITHUB_TOKEN_HOSTS = frozenset({"api.github.com", "raw.githubusercontent.com"})
|
||||
|
||||
|
||||
def _is_github_token_url(url: str) -> bool:
|
||||
try:
|
||||
parsed_url = httpx.URL(url)
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
parsed_url.scheme == "https"
|
||||
and parsed_url.host in _GITHUB_TOKEN_HOSTS
|
||||
and parsed_url.port is None
|
||||
and not parsed_url.userinfo
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -11,8 +13,45 @@ def _do_nothing(*_):
|
|||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||
unregister_wasm_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||
|
||||
|
||||
class WasmExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
def register(self, extension: Any) -> None:
|
||||
self.require_available(extension)
|
||||
self._extensions[extension.id] = extension
|
||||
|
||||
def require_available(self, extension: Any) -> None:
|
||||
existing = self._extensions.get(extension.id)
|
||||
if existing and not _same_wasm_extension_registration(existing, extension):
|
||||
raise ValueError(
|
||||
f"WASM extension id '{extension.id}' is already registered."
|
||||
)
|
||||
|
||||
def get(self, ext_id: str) -> Any | None:
|
||||
return self._extensions.get(ext_id)
|
||||
|
||||
def unregister(self, ext_id: str) -> None:
|
||||
self._extensions.pop(ext_id, None)
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
return list(self._extensions.values())
|
||||
|
||||
|
||||
def _same_wasm_extension_registration(left: Any, right: Any) -> bool:
|
||||
try:
|
||||
return Path(left.root_path).resolve() == Path(right.root_path).resolve()
|
||||
except (AttributeError, TypeError):
|
||||
return left is right
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
|
|
@ -41,6 +80,7 @@ class SimpleStatus(BaseModel):
|
|||
class SimpleItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
class DbVersion(BaseModel):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from lnbits.db import FilterModel
|
|||
from lnbits.fiat.base import (
|
||||
FiatPaymentStatus,
|
||||
)
|
||||
from lnbits.helpers import is_valid_external_id
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets.base import (
|
||||
PaymentStatus,
|
||||
|
|
@ -34,6 +35,11 @@ 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
|
||||
|
|
@ -48,11 +54,17 @@ class CreatePayment(BaseModel):
|
|||
amount_msat: int
|
||||
memo: str
|
||||
extra: dict | None = {}
|
||||
extension: str | None = None
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
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):
|
||||
|
|
@ -77,6 +89,11 @@ 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)
|
||||
|
|
@ -124,22 +141,18 @@ class Payment(BaseModel):
|
|||
)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
logger.warning("payment.check_status() is deprecated.")
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
|
||||
return await check_payment_status(self, skip_internal_payment_notifications)
|
||||
return await check_payment_status(self)
|
||||
|
||||
# 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:
|
||||
async def check_fiat_status(self) -> FiatPaymentStatus:
|
||||
logger.warning("payment.check_fiat_status() is deprecated.")
|
||||
from lnbits.core.services.fiat_providers import check_fiat_status
|
||||
|
||||
return await check_fiat_status(self, skip_internal_payment_notifications)
|
||||
return await check_fiat_status(self)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
|
|
@ -151,6 +164,7 @@ class PaymentFilters(FilterModel):
|
|||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
__sort_fields__ = [
|
||||
|
|
@ -161,11 +175,13 @@ 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
|
||||
|
|
@ -205,6 +221,13 @@ class PaymentWalletStats(BaseModel):
|
|||
balance: float = 0
|
||||
|
||||
|
||||
class PaymentTotalBreakdown(BaseModel):
|
||||
tag: str | None = None
|
||||
is_fiat: bool = False
|
||||
payments_count: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class PaymentDailyStats(BaseModel):
|
||||
date: datetime
|
||||
balance: float = 0
|
||||
|
|
@ -244,11 +267,16 @@ class CreateInvoice(BaseModel):
|
|||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
extension: str | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
def is_fiat_subscription(self) -> bool:
|
||||
return (self.extra or {}).get("fiat_method") == "subscription"
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
|
|
@ -263,6 +291,10 @@ 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
|
||||
|
|
@ -301,3 +333,12 @@ 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
lnbits/core/models/sso/__init__.py
Normal file
1
lnbits/core/models/sso/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""SSO authentication providers for LNbits"""
|
||||
36
lnbits/core/models/sso/oidc.py
Normal file
36
lnbits/core/models/sso/oidc.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""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,4 +1,6 @@
|
|||
from pydantic import BaseModel
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
|
|
@ -6,4 +8,4 @@ class TinyURL(BaseModel):
|
|||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ class Wallet(BaseWallet):
|
|||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
currency: str | None = None
|
||||
lightning_address: str | None = None
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
|
|
@ -150,6 +151,7 @@ class Wallet(BaseWallet):
|
|||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.lightning_address = shared_wallet.lightning_address
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
|
|
@ -240,10 +242,18 @@ class BaseWalletTypeInfo:
|
|||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
__search_fields__ = ["id", "name", "currency", "lightning_address"]
|
||||
|
||||
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
|
||||
__sort_fields__ = [
|
||||
"id",
|
||||
"name",
|
||||
"currency",
|
||||
"lightning_address",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
id: str | None
|
||||
name: str | None
|
||||
currency: str | None
|
||||
lightning_address: str | None
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
from .blockexplorer import (
|
||||
fetch_fee_estimates,
|
||||
fetch_onchain_balance,
|
||||
fetch_recent_blocks,
|
||||
fetch_tip,
|
||||
fetch_transaction,
|
||||
)
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .funding_source import (
|
||||
get_balance_delta,
|
||||
|
|
@ -56,7 +63,12 @@ __all__ = [
|
|||
"enqueue_admin_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"fetch_fee_estimates",
|
||||
"fetch_lnurl_pay_request",
|
||||
"fetch_onchain_balance",
|
||||
"fetch_recent_blocks",
|
||||
"fetch_tip",
|
||||
"fetch_transaction",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"get_pr_from_lnurl",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
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
|
||||
|
|
@ -10,11 +12,48 @@ 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.")
|
||||
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
|
||||
|
||||
content_type = normalize_asset_mime_type(file.content_type)
|
||||
filename = file.filename or "unnamed"
|
||||
|
||||
if content_type not in allowed_asset_mime_types():
|
||||
raise ValueError(f"File type '{file.content_type}' not allowed.")
|
||||
|
||||
if not settings.is_unlimited_assets_user(user_id):
|
||||
|
|
@ -30,14 +69,26 @@ 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}'."
|
||||
)
|
||||
|
||||
thumb_buffer = thumbnail_from_bytes(contents)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
mime_type=file.content_type,
|
||||
mime_type=stored_mime_type,
|
||||
is_public=is_public,
|
||||
name=file.filename or "unnamed",
|
||||
name=filename,
|
||||
size_bytes=len(contents),
|
||||
thumbnail_base64=(
|
||||
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
|
||||
|
|
@ -51,6 +102,79 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
|||
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))
|
||||
|
|
|
|||
97
lnbits/core/services/blockexplorer.py
Normal file
97
lnbits/core/services/blockexplorer.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import asyncio
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import OnchainAddressEvent
|
||||
from lnbits.utils.electrum import (
|
||||
UTXO,
|
||||
AddressResponse,
|
||||
Balance,
|
||||
BlockHeader,
|
||||
BlockInfo,
|
||||
ElectrumClient,
|
||||
FeeResponse,
|
||||
Transaction,
|
||||
network_from_name,
|
||||
parse_block_header,
|
||||
parse_raw_tx,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
|
||||
def _client() -> ElectrumClient:
|
||||
return ElectrumClient(
|
||||
settings.lnbits_blockexplorer_electrum_url,
|
||||
network=network_from_name(settings.lnbits_blockexplorer_network),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_recent_blocks(count: int = 5) -> list[BlockInfo]:
|
||||
async with _client() as c:
|
||||
tip = await c.get_tip()
|
||||
start = max(0, tip.height - count + 1)
|
||||
headers = await c.get_block_headers(start, tip.height - start + 1)
|
||||
raw = bytes.fromhex(headers.hex)
|
||||
blocks = [
|
||||
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
|
||||
for i in range(headers.count)
|
||||
]
|
||||
return list(reversed(blocks))
|
||||
|
||||
|
||||
async def fetch_tip() -> BlockHeader:
|
||||
async with _client() as c:
|
||||
return await c.get_tip()
|
||||
|
||||
|
||||
async def fetch_fee_estimates() -> FeeResponse:
|
||||
async with _client() as c:
|
||||
estimates_raw = await asyncio.gather(
|
||||
c.estimate_fee(1),
|
||||
c.estimate_fee(3),
|
||||
c.estimate_fee(6),
|
||||
c.estimate_fee(144),
|
||||
)
|
||||
histogram = await c.fee_histogram()
|
||||
estimates = {
|
||||
str(blocks): fee
|
||||
for blocks, fee in zip([1, 3, 6, 144], estimates_raw, strict=False)
|
||||
if fee >= 0
|
||||
}
|
||||
return FeeResponse(estimates=estimates, histogram=histogram)
|
||||
|
||||
|
||||
async def fetch_transaction(txid: str) -> Transaction:
|
||||
async with _client() as c:
|
||||
raw_hex = await c.get_transaction(txid)
|
||||
return parse_raw_tx(raw_hex, network=c.network)
|
||||
|
||||
|
||||
async def fetch_onchain_balance(onchain_address: str) -> AddressResponse:
|
||||
scripthash = scripthash_from_address(onchain_address)
|
||||
async with _client() as client:
|
||||
balance_res, history_res = await asyncio.gather(
|
||||
client.get_balance(scripthash),
|
||||
client.get_history(scripthash),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(balance_res, BaseException):
|
||||
raise balance_res
|
||||
history = [] if isinstance(history_res, BaseException) else history_res
|
||||
history_error = str(history_res) if isinstance(history_res, BaseException) else None
|
||||
return AddressResponse(
|
||||
balance=balance_res, history=history, history_error=history_error
|
||||
)
|
||||
|
||||
|
||||
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
|
||||
scripthash = scripthash_from_address(onchain_address)
|
||||
async with _client() as client:
|
||||
return await client.listunspent(scripthash)
|
||||
|
||||
|
||||
def address_event_to_response(event: OnchainAddressEvent) -> AddressResponse:
|
||||
return AddressResponse(
|
||||
balance=Balance(confirmed=event.confirmed, unconfirmed=event.unconfirmed),
|
||||
history=event.history,
|
||||
history_error=event.history_error,
|
||||
)
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -9,21 +16,178 @@ 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 (
|
||||
create_wasm_invocation,
|
||||
delete_old_wasm_invocations,
|
||||
get_installed_extensions,
|
||||
get_wasm_invocation,
|
||||
mark_stale_wasm_invocations,
|
||||
update_installed_extension,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_wasm_invocation,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocation_stats as get_wasm_invocation_stats_crud,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocations as get_wasm_invocations_crud,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import settings
|
||||
from lnbits.settings import WasmRuntimeLimits, settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
from ..models.extensions import (
|
||||
Extension,
|
||||
ExtensionMeta,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
|
||||
_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1)
|
||||
WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys())
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasmInvocationHandle:
|
||||
invocation: WasmInvocation
|
||||
engine: Any | None = None
|
||||
store: Any | None = None
|
||||
runtime_limits: dict[str, int] | None = None
|
||||
stop_requested: bool = False
|
||||
stop_reason: str | None = None
|
||||
|
||||
|
||||
_wasm_invocation_lock = RLock()
|
||||
_wasm_invocation_ready_lock = asyncio.Lock()
|
||||
_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {}
|
||||
_wasm_invocations_marked_stale = False
|
||||
_wasm_invocations_last_cleanup_at: datetime | None = None
|
||||
|
||||
|
||||
def wasm_runtime_limit_defaults() -> dict[str, int]:
|
||||
return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS}
|
||||
|
||||
|
||||
def validate_wasm_runtime_limit_overrides(
|
||||
limits: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> dict[str, int]:
|
||||
if not limits:
|
||||
return {}
|
||||
|
||||
validated: dict[str, int] = {}
|
||||
for field, raw_value in limits.items():
|
||||
if field not in WASM_RUNTIME_LIMIT_FIELDS:
|
||||
if strict:
|
||||
raise ValueError(f"Unknown WASM runtime limit field '{field}'.")
|
||||
continue
|
||||
|
||||
value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict)
|
||||
if value is None:
|
||||
continue
|
||||
validated[field] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def _validate_wasm_runtime_limit_value(
|
||||
field: str,
|
||||
raw_value: Any,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> int | None:
|
||||
if raw_value is None or raw_value == "":
|
||||
return None
|
||||
if isinstance(raw_value, bool):
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if raw_value == "":
|
||||
return None
|
||||
if not raw_value.isdecimal():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return _invalid_wasm_runtime_limit(
|
||||
field,
|
||||
strict,
|
||||
"must be an integer",
|
||||
exc=exc,
|
||||
)
|
||||
if value < 0:
|
||||
return _invalid_wasm_runtime_limit(field, strict, "cannot be negative")
|
||||
return value
|
||||
|
||||
|
||||
def _invalid_wasm_runtime_limit(
|
||||
field: str,
|
||||
strict: bool,
|
||||
message: str,
|
||||
*,
|
||||
exc: Exception | None = None,
|
||||
) -> int | None:
|
||||
if not strict:
|
||||
return None
|
||||
error = ValueError(f"WASM runtime limit '{field}' {message}.")
|
||||
if exc:
|
||||
raise error from exc
|
||||
raise error
|
||||
|
||||
|
||||
def resolve_wasm_runtime_limits(
|
||||
installed_extension: InstallableExtension | None = None,
|
||||
) -> dict[str, int]:
|
||||
limits = wasm_runtime_limit_defaults()
|
||||
if installed_extension:
|
||||
limits.update(
|
||||
validate_wasm_runtime_limit_overrides(
|
||||
installed_extension.wasm_runtime_limits,
|
||||
strict=False,
|
||||
)
|
||||
)
|
||||
return limits
|
||||
|
||||
|
||||
async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
return resolve_wasm_runtime_limits(installed_extension)
|
||||
|
||||
|
||||
async def update_wasm_extension_runtime_limits(
|
||||
ext_id: str,
|
||||
limits: Mapping[str, Any] | None,
|
||||
) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
if not installed_extension:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not installed_extension.is_wasm:
|
||||
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
|
||||
|
||||
validated_limits = validate_wasm_runtime_limit_overrides(limits)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext_id,
|
||||
limits=validated_limits,
|
||||
)
|
||||
return validated_limits
|
||||
|
||||
|
||||
async def install_extension(
|
||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
||||
ext_info: InstallableExtension,
|
||||
skip_download: bool | None = False,
|
||||
granted_permissions: list[ExtensionPermission] | None = None,
|
||||
allow_admin_policy_overrides: bool = False,
|
||||
) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
|
|
@ -37,11 +201,27 @@ async def install_extension(
|
|||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
if installed_ext:
|
||||
ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
ext_info.extract_archive()
|
||||
extension_config = ext_info.load_archive_config()
|
||||
ext_info.validate_archive(extension_config)
|
||||
ext_info.permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
granted_permissions,
|
||||
extension_config,
|
||||
allow_admin_policy_overrides=allow_admin_policy_overrides,
|
||||
)
|
||||
|
||||
if extension_config.get("extension_type") == "wasm":
|
||||
ext_info.extract_wasm_archive()
|
||||
else:
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
await migrate_extension_database(ext_info, db_version)
|
||||
|
|
@ -53,34 +233,438 @@ async def install_extension(
|
|||
else:
|
||||
await update_installed_extension(ext_info)
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
if extension.is_upgrade_extension:
|
||||
# call stop while the old routes are still active
|
||||
if installed_ext:
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
await start_extension_background_work(ext_info.id)
|
||||
return Extension.from_installable_ext(ext_info)
|
||||
|
||||
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 ensure_wasm_invocation_monitoring_ready() -> None:
|
||||
global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale
|
||||
|
||||
async with _wasm_invocation_ready_lock:
|
||||
now = _now()
|
||||
if not _wasm_invocations_marked_stale:
|
||||
await mark_stale_wasm_invocations()
|
||||
_wasm_invocations_marked_stale = True
|
||||
|
||||
if (
|
||||
_wasm_invocations_last_cleanup_at is None
|
||||
or now - _wasm_invocations_last_cleanup_at
|
||||
>= _WASM_INVOCATION_CLEANUP_INTERVAL
|
||||
):
|
||||
_wasm_invocations_last_cleanup_at = now
|
||||
await delete_old_wasm_invocations(
|
||||
settings.lnbits_wasm_invocation_retention_days
|
||||
)
|
||||
|
||||
|
||||
async def start_wasm_invocation(
|
||||
*,
|
||||
extension_id: str,
|
||||
export_name: str,
|
||||
trigger_type: str = "unknown",
|
||||
user_id: str | None = None,
|
||||
wallet_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
event_type: str | None = None,
|
||||
payment_hash: str | None = None,
|
||||
checking_id: str | None = None,
|
||||
request_bytes: int | None = None,
|
||||
context: dict | None = None,
|
||||
runtime_limits: dict[str, int] | None = None,
|
||||
) -> WasmInvocation:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
_check_wasm_invocation_concurrency(
|
||||
extension_id=extension_id,
|
||||
user_id=user_id,
|
||||
limits=runtime_limits,
|
||||
)
|
||||
|
||||
invocation = WasmInvocation(
|
||||
id=uuid4().hex,
|
||||
extension_id=extension_id,
|
||||
export_name=export_name,
|
||||
trigger_type=trigger_type,
|
||||
user_id=user_id,
|
||||
wallet_id=wallet_id,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
path=path,
|
||||
event_type=event_type,
|
||||
payment_hash=payment_hash,
|
||||
checking_id=checking_id,
|
||||
request_bytes=request_bytes,
|
||||
context=_safe_wasm_invocation_context(context or {}),
|
||||
)
|
||||
await create_wasm_invocation(invocation)
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
_wasm_invocation_handles[invocation.id] = WasmInvocationHandle(
|
||||
invocation,
|
||||
runtime_limits=runtime_limits,
|
||||
)
|
||||
|
||||
return invocation
|
||||
|
||||
|
||||
def attach_wasm_invocation_runtime(
|
||||
invocation_id: str,
|
||||
*,
|
||||
engine: Any,
|
||||
store: Any,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
handle.engine = engine
|
||||
handle.store = store
|
||||
if handle.stop_requested:
|
||||
_interrupt_wasm_invocation(handle)
|
||||
|
||||
|
||||
def record_wasm_invocation_host_call(
|
||||
invocation_id: str | None,
|
||||
method_id: str,
|
||||
) -> None:
|
||||
if not invocation_id:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
|
||||
invocation = handle.invocation
|
||||
invocation.host_call_count += 1
|
||||
category = _wasm_host_call_category(method_id)
|
||||
if category == "http":
|
||||
invocation.http_call_count += 1
|
||||
elif category == "storage":
|
||||
invocation.storage_call_count += 1
|
||||
elif category == "wallet":
|
||||
invocation.wallet_call_count += 1
|
||||
|
||||
_check_wasm_host_call_limit(invocation, category, handle.runtime_limits)
|
||||
|
||||
|
||||
async def stop_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
reason: str = "Stopped by admin.",
|
||||
) -> bool:
|
||||
interrupted = False
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if handle:
|
||||
handle.stop_requested = True
|
||||
handle.stop_reason = reason
|
||||
handle.invocation.stop_reason = reason
|
||||
interrupted = _interrupt_wasm_invocation(handle)
|
||||
|
||||
invocation = await get_wasm_invocation(invocation_id)
|
||||
if invocation and invocation.status == "running":
|
||||
invocation.stop_reason = reason
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
return interrupted
|
||||
|
||||
|
||||
async def stop_wasm_extension_invocations(
|
||||
extension_id: str,
|
||||
*,
|
||||
reason: str = "Extension deactivated.",
|
||||
) -> int:
|
||||
with _wasm_invocation_lock:
|
||||
invocation_ids = [
|
||||
invocation_id
|
||||
for invocation_id, handle in _wasm_invocation_handles.items()
|
||||
if handle.invocation.extension_id == extension_id
|
||||
]
|
||||
|
||||
for invocation_id in invocation_ids:
|
||||
await stop_wasm_invocation(invocation_id, reason=reason)
|
||||
|
||||
return len(invocation_ids)
|
||||
|
||||
|
||||
def wasm_invocation_stop_requested(invocation_id: str) -> bool:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return bool(handle and handle.stop_requested)
|
||||
|
||||
|
||||
def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return handle.stop_reason if handle else None
|
||||
|
||||
|
||||
async def finish_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
status: str,
|
||||
response_bytes: int | None = None,
|
||||
memory_peak_bytes: int | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
stop_reason: str | None = None,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.pop(invocation_id, None)
|
||||
|
||||
invocation = (
|
||||
handle.invocation if handle else await get_wasm_invocation(invocation_id)
|
||||
)
|
||||
if not invocation:
|
||||
return
|
||||
|
||||
reason = stop_reason or (handle.stop_reason if handle else None)
|
||||
if handle and handle.stop_requested and status == "failed":
|
||||
status = "stopped"
|
||||
reason = reason or "Stopped by admin."
|
||||
|
||||
finished_at = _now()
|
||||
invocation.status = status
|
||||
invocation.finished_at = finished_at
|
||||
invocation.duration_ms = max(
|
||||
0, int((finished_at - invocation.started_at).total_seconds() * 1000)
|
||||
)
|
||||
invocation.response_bytes = response_bytes
|
||||
invocation.memory_peak_bytes = memory_peak_bytes
|
||||
invocation.error_type = error_type
|
||||
invocation.error_message = _safe_wasm_error_message(error_message)
|
||||
invocation.stop_reason = reason
|
||||
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
|
||||
def get_current_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
with _wasm_invocation_lock:
|
||||
invocations = []
|
||||
for handle in _wasm_invocation_handles.values():
|
||||
if extension_id and handle.invocation.extension_id != extension_id:
|
||||
continue
|
||||
invocation = handle.invocation.copy(deep=True)
|
||||
if handle.stop_requested and invocation.status == "running":
|
||||
invocation.status = "stopping"
|
||||
invocation.stop_reason = handle.stop_reason
|
||||
invocations.append(invocation)
|
||||
|
||||
return sorted(
|
||||
invocations, key=lambda invocation: invocation.started_at, reverse=True
|
||||
)
|
||||
|
||||
|
||||
def _check_wasm_invocation_concurrency(
|
||||
*,
|
||||
extension_id: str,
|
||||
user_id: str | None,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handles = list(_wasm_invocation_handles.values())
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations"],
|
||||
len(handles) + 1,
|
||||
):
|
||||
raise ValueError("WASM runtime has too many active invocations.")
|
||||
|
||||
extension_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.extension_id == extension_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_extension"],
|
||||
extension_invocations + 1,
|
||||
):
|
||||
raise ValueError(
|
||||
f"WASM extension '{extension_id}' has too many active invocations."
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
user_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.user_id == user_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_user"],
|
||||
user_invocations + 1,
|
||||
):
|
||||
raise ValueError("WASM user has too many active invocations.")
|
||||
|
||||
|
||||
def _check_wasm_host_call_limit(
|
||||
invocation: WasmInvocation,
|
||||
category: str,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_host_calls"],
|
||||
invocation.host_call_count,
|
||||
):
|
||||
raise ValueError("WASM host call limit exceeded.")
|
||||
|
||||
category_limits = {
|
||||
"http": (
|
||||
limits["wasm_runtime_max_http_calls"],
|
||||
invocation.http_call_count,
|
||||
),
|
||||
"storage": (
|
||||
limits["wasm_runtime_max_storage_calls"],
|
||||
invocation.storage_call_count,
|
||||
),
|
||||
"wallet": (
|
||||
limits["wasm_runtime_max_wallet_calls"],
|
||||
invocation.wallet_call_count,
|
||||
),
|
||||
}
|
||||
category_limit = category_limits.get(category)
|
||||
if category_limit and _wasm_limit_exceeded(*category_limit):
|
||||
raise ValueError(f"WASM {category} host call limit exceeded.")
|
||||
|
||||
|
||||
def _wasm_limit_exceeded(limit: int, value: int) -> bool:
|
||||
return limit > 0 and value > limit
|
||||
|
||||
|
||||
async def get_wasm_invocation_history(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[WasmInvocation]:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
return await get_wasm_invocations_crud(
|
||||
extension_id=extension_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocation_summary(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
hours: int = 24,
|
||||
) -> WasmInvocationStats:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
since = _now() - timedelta(hours=max(1, min(hours, 24 * 30)))
|
||||
return await get_wasm_invocation_stats_crud(
|
||||
extension_id=extension_id,
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool:
|
||||
if not handle.store or not handle.engine:
|
||||
return False
|
||||
try:
|
||||
handle.store.set_epoch_deadline(1)
|
||||
handle.engine.increment_epoch()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_host_call_category(method_id: str) -> str:
|
||||
if method_id.startswith("http.") or method_id.startswith("extension.api."):
|
||||
return "http"
|
||||
if method_id.startswith("storage."):
|
||||
return "storage"
|
||||
if method_id.startswith("wallet."):
|
||||
return "wallet"
|
||||
return "host"
|
||||
|
||||
|
||||
def _safe_wasm_invocation_context(context: dict) -> dict:
|
||||
safe_context: dict = {}
|
||||
for key, value in context.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
safe_context[key[:64]] = value
|
||||
elif isinstance(value, str):
|
||||
safe_context[key[:64]] = value[:256]
|
||||
return safe_context
|
||||
|
||||
|
||||
def _safe_wasm_error_message(message: str | None) -> str | None:
|
||||
if not message:
|
||||
return None
|
||||
|
||||
safe_message = message[:500]
|
||||
redactions = [
|
||||
(
|
||||
r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)"
|
||||
r"\s*[:=]\s*[^\s,;]+",
|
||||
r"\1=[redacted]",
|
||||
),
|
||||
(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"),
|
||||
(r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"),
|
||||
]
|
||||
for pattern, replacement in redactions:
|
||||
safe_message = re.sub(pattern, replacement, safe_message)
|
||||
return safe_message
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
core_app_extra.unregister_wasm_ext_routes(ext_id)
|
||||
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if extension:
|
||||
extension.clean_extension_files()
|
||||
if extension.is_wasm:
|
||||
extension.clean_wasm_extension_files()
|
||||
else:
|
||||
extension.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
if ext.is_wasm:
|
||||
core_app_extra.register_new_wasm_ext_routes(ext.code)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
return
|
||||
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
await start_extension_background_work(ext.code)
|
||||
|
||||
|
||||
async def deactivate_extension(ext_id: str):
|
||||
if is_wasm_extension_id(ext_id):
|
||||
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
|
@ -91,16 +675,19 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
|||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
||||
"""
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return True
|
||||
|
||||
ext = Extension(code=ext_id, is_valid=True)
|
||||
module_name = ext.module_name
|
||||
|
||||
try:
|
||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
logger.info(f"Stopping background work for extension '{module_name}'.")
|
||||
old_module = importlib.import_module(module_name)
|
||||
|
||||
stop_fn_name = f"{ext_id}_stop"
|
||||
if not hasattr(old_module, stop_fn_name):
|
||||
raise ValueError(f"No stop function found for '{ext.module_name}'.")
|
||||
raise ValueError(f"No stop function found for '{module_name}'.")
|
||||
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
|
|
@ -108,9 +695,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
|||
await stop_fn()
|
||||
else:
|
||||
stop_fn()
|
||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||
logger.info(f"Stopped background work for extension '{module_name}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||
logger.warning(f"Failed to stop background work for '{module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
|
|
@ -123,12 +710,15 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
|||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return False
|
||||
|
||||
ext = Extension(code=ext_id, is_valid=True)
|
||||
module_name = ext.module_name
|
||||
|
||||
try:
|
||||
logger.info(f"Starting background work for extension '{ext.module_name}'.")
|
||||
new_module = importlib.import_module(ext.module_name)
|
||||
logger.info(f"Starting background work for extension '{module_name}'.")
|
||||
new_module = importlib.import_module(module_name)
|
||||
start_fn_name = f"{ext_id}_start"
|
||||
|
||||
# start function is optional, return False if not found
|
||||
|
|
@ -141,10 +731,10 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
|||
await start_fn()
|
||||
else:
|
||||
start_fn()
|
||||
logger.info(f"Started background work for extension '{ext.module_name}'.")
|
||||
logger.info(f"Started background work for extension '{module_name}'.")
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to start background work for '{ext.module_name}'.")
|
||||
logger.warning(f"Failed to start background work for '{module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import hashlib
|
|||
import hmac
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment
|
||||
from lnbits.core.crud.payments import create_payment, update_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
|
|
@ -19,6 +20,7 @@ from lnbits.fiat.base import (
|
|||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
|
|
@ -35,9 +37,7 @@ async def handle_fiat_payment_confirmation(
|
|||
logger.warning(e)
|
||||
|
||||
|
||||
async def check_fiat_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
if not payment.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment.success:
|
||||
|
|
@ -57,14 +57,11 @@ async def check_fiat_status(
|
|||
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)
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
|
@ -169,6 +166,82 @@ 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.
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ async def check_server_balance_against_node():
|
|||
|
||||
|
||||
async def check_balance_delta_changed():
|
||||
if settings.notification_balance_delta_threshold_sats <= 0:
|
||||
return
|
||||
status = await get_balance_delta()
|
||||
if settings.latest_balance_delta_sats is None:
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
|
|
|
|||
242
lnbits/core/services/lightning_address.py
Normal file
242
lnbits/core/services/lightning_address.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from fastapi import Query, Request
|
||||
from lnurl import (
|
||||
CallbackUrl,
|
||||
LightningInvoice,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayMetadata,
|
||||
LnurlPayResponse,
|
||||
MilliSatoshi,
|
||||
)
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
from lnbits.core.crud.wallets import (
|
||||
get_wallet,
|
||||
get_wallet_id_by_ln_address,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.models.wallets import Wallet
|
||||
from lnbits.core.services.payments import (
|
||||
create_invoice,
|
||||
create_wallet_invoice,
|
||||
pay_invoice,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.exceptions import PaymentError
|
||||
from lnbits.settings import settings
|
||||
|
||||
MAX_SENDABLE_MSAT = 2_100_000_000_000_000_000
|
||||
COMMENT_ALLOWED = 799
|
||||
LIGHTNING_ADDRESS_REGEX = re.compile(r"^[a-z0-9_.-]{1,210}$")
|
||||
|
||||
|
||||
async def set_wallet_lightning_address(
|
||||
*,
|
||||
wallet: Wallet,
|
||||
local_part: str,
|
||||
allow_blacklisted: bool = False,
|
||||
charge: bool = False,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
if not settings.ln_address_creation_allowed:
|
||||
raise ValueError("Wallet Lightning Addresses are disabled.")
|
||||
if not wallet.is_lightning_wallet or wallet.deleted:
|
||||
raise ValueError("Lightning Address can only be set for active wallets.")
|
||||
|
||||
local_part = await _validate_local_part(
|
||||
local_part, wallet.id, allow_blacklisted, conn=conn
|
||||
)
|
||||
if wallet.lightning_address == local_part:
|
||||
return wallet
|
||||
|
||||
if charge:
|
||||
await _charge_for_lightning_address(wallet)
|
||||
|
||||
wallet.lightning_address = local_part
|
||||
return await update_wallet(wallet, conn=conn)
|
||||
|
||||
|
||||
async def wallet_lightning_address_response(
|
||||
username: str, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
local_part, tag = _split_tagged_local_part(username)
|
||||
wallet_id = await get_wallet_id_by_ln_address(local_part)
|
||||
if not wallet_id:
|
||||
return LnurlErrorResponse(reason="Lightning address not found.")
|
||||
|
||||
tagged_local_part = local_part
|
||||
if tag:
|
||||
tagged_local_part = f"{tagged_local_part}+{tag}"
|
||||
|
||||
callback = request.url_for(
|
||||
"lnurl.api_wallet_lightning_address_callback",
|
||||
username=tagged_local_part,
|
||||
)
|
||||
identifier = _lightning_address_for_request(request, tagged_local_part)
|
||||
return LnurlPayResponse(
|
||||
callback=parse_obj_as(CallbackUrl, str(callback)),
|
||||
minSendable=MilliSatoshi(1000),
|
||||
maxSendable=MilliSatoshi(MAX_SENDABLE_MSAT),
|
||||
metadata=LnurlPayMetadata(json.dumps(_metadata(identifier, tag))),
|
||||
commentAllowed=COMMENT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
async def wallet_lightning_address_callback(
|
||||
username: str,
|
||||
request: Request,
|
||||
amount: int = Query(...),
|
||||
) -> LnurlErrorResponse | LnurlPayActionResponse:
|
||||
local_part, tag = _split_tagged_local_part(username)
|
||||
wallet_id = await get_wallet_id_by_ln_address(local_part)
|
||||
if not wallet_id:
|
||||
return LnurlErrorResponse(reason="Lightning address not found.")
|
||||
|
||||
if amount < 1000:
|
||||
return LnurlErrorResponse(reason="Amount is smaller than minimum 1000.")
|
||||
if amount > MAX_SENDABLE_MSAT:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount is greater than maximum {MAX_SENDABLE_MSAT}."
|
||||
)
|
||||
|
||||
comment = request.query_params.get("comment")
|
||||
if len(comment or "") > COMMENT_ALLOWED:
|
||||
return LnurlErrorResponse(
|
||||
reason=(
|
||||
f"Got a comment with {len(comment or '')} characters, "
|
||||
f"but can only accept {COMMENT_ALLOWED}"
|
||||
)
|
||||
)
|
||||
|
||||
tagged_local_part = local_part
|
||||
if tag:
|
||||
tagged_local_part = f"{tagged_local_part}+{tag}"
|
||||
identifier = _lightning_address_for_request(request, tagged_local_part)
|
||||
extra = {
|
||||
"tag": "wallet_lightning_address",
|
||||
"lnaddress": identifier,
|
||||
}
|
||||
if tag:
|
||||
extra["lnaddress_tag"] = tag
|
||||
if comment:
|
||||
extra["comment"] = comment
|
||||
|
||||
metadata = LnurlPayMetadata(json.dumps(_metadata(identifier, tag)))
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(amount / 1000),
|
||||
memo=f"Payment to {identifier}",
|
||||
unhashed_description=metadata.encode(),
|
||||
extra=extra,
|
||||
)
|
||||
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
|
||||
return LnurlPayActionResponse(pr=invoice, disposable=False)
|
||||
|
||||
|
||||
def _lightning_address_for_request(request: Request, local_part: str) -> str:
|
||||
return f"{local_part}@{request.url.netloc}"
|
||||
|
||||
|
||||
async def _validate_local_part(
|
||||
local_part: str,
|
||||
wallet_id: str,
|
||||
allow_blacklisted: bool = False,
|
||||
conn: Connection | None = None,
|
||||
) -> str:
|
||||
local_part = local_part.strip().lower()
|
||||
if not local_part:
|
||||
raise ValueError("Lightning Address is required.")
|
||||
if "+" in local_part:
|
||||
raise ValueError("Lightning Address cannot include tags.")
|
||||
if "@" in local_part:
|
||||
raise ValueError("Enter only the Lightning Address name before @.")
|
||||
if not LIGHTNING_ADDRESS_REGEX.match(local_part):
|
||||
raise ValueError(
|
||||
"Lightning Address can only contain lowercase letters, numbers, "
|
||||
"dash, underscore, and dot."
|
||||
)
|
||||
if not allow_blacklisted and _uses_blacklisted_word(local_part):
|
||||
raise ValueError("Lightning Address contains a reserved word.")
|
||||
existing_wallet_id = await get_wallet_id_by_ln_address(local_part, conn=conn)
|
||||
if existing_wallet_id and existing_wallet_id != wallet_id:
|
||||
raise ValueError("Lightning Address is already taken.")
|
||||
|
||||
return local_part
|
||||
|
||||
|
||||
def _split_tagged_local_part(local_part: str) -> tuple[str, str | None]:
|
||||
username, separator, tag = local_part.partition("+")
|
||||
if not separator or not tag:
|
||||
return username.lower(), None
|
||||
return username.lower(), tag
|
||||
|
||||
|
||||
def _metadata(identifier: str, tag: str | None = None) -> list[list[str]]:
|
||||
metadata = [
|
||||
["text/plain", f"Payment to {identifier}"],
|
||||
["text/identifier", identifier],
|
||||
]
|
||||
if tag:
|
||||
metadata.append(["text/tag", tag])
|
||||
return metadata
|
||||
|
||||
|
||||
async def _charge_for_lightning_address(wallet: Wallet) -> None:
|
||||
price_sats = settings.lnbits_wallet_lightning_address_price_sats
|
||||
if not settings.lnbits_charge_wallet_lightning_addresses or price_sats <= 0:
|
||||
return
|
||||
if not settings.lnbits_service_fee_wallet:
|
||||
raise ValueError("Lightning Address fee wallet is not configured.")
|
||||
if settings.lnbits_service_fee_wallet == wallet.source_wallet_id:
|
||||
raise ValueError("Lightning Address fee wallet cannot be the same wallet.")
|
||||
|
||||
fee_wallet = await get_wallet(settings.lnbits_service_fee_wallet)
|
||||
if not fee_wallet:
|
||||
raise ValueError("Lightning Address fee wallet is not configured.")
|
||||
|
||||
invoice = await create_wallet_invoice(
|
||||
settings.lnbits_service_fee_wallet,
|
||||
CreateInvoice(
|
||||
out=False,
|
||||
amount=price_sats,
|
||||
memo="Lightning Address fee",
|
||||
internal=True,
|
||||
extra={
|
||||
"tag": "wallet_lightning_address_fee",
|
||||
"wallet": wallet.source_wallet_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
payment_request=invoice.bolt11,
|
||||
description="Lightning Address fee",
|
||||
tag="wallet_lightning_address_fee",
|
||||
extra={
|
||||
"tag": "wallet_lightning_address_fee",
|
||||
"fee_wallet": settings.lnbits_service_fee_wallet,
|
||||
},
|
||||
)
|
||||
except PaymentError as exc:
|
||||
raise ValueError(exc.message) from exc
|
||||
|
||||
|
||||
def _blacklist_words() -> set[str]:
|
||||
return {
|
||||
word.strip().lower()
|
||||
for word in settings.lnbits_wallet_lightning_address_blacklist
|
||||
if word.strip()
|
||||
}
|
||||
|
||||
|
||||
def _uses_blacklisted_word(local_part: str) -> bool:
|
||||
words = _blacklist_words()
|
||||
if not words:
|
||||
return False
|
||||
segments = [segment for segment in re.split(r"[._-]+", local_part) if segment]
|
||||
return local_part in words or any(segment in words for segment in segments)
|
||||
|
|
@ -74,7 +74,7 @@ async def send_admin_notification(
|
|||
message: str,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
|
|
@ -97,7 +97,7 @@ async def send_user_notification(
|
|||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
|
|
@ -222,12 +222,29 @@ async def send_email(
|
|||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
username = username if len(username) > 0 else from_email
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
|
||||
def _send() -> bool:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_send)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sending Email failed. {e!s}")
|
||||
return False
|
||||
|
||||
|
||||
async def dispatch_payment_notification(payment: Payment) -> None:
|
||||
"""
|
||||
This worker dispatches the payment notifications.
|
||||
"""
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
|
|
@ -294,6 +311,27 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
|||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_notification_in_background(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
create_task(
|
||||
send_notification(
|
||||
telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_addresses,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending notification in background: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ 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.helpers import check_callback_url, daystart_timestamp
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.task_manager import task_manager
|
||||
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
|
||||
|
|
@ -64,6 +64,7 @@ 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:
|
||||
|
|
@ -97,6 +98,7 @@ 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:
|
||||
|
|
@ -116,6 +118,8 @@ async def create_payment_request(
|
|||
Create a lightning invoice or a fiat payment request.
|
||||
"""
|
||||
if invoice_data.fiat_provider:
|
||||
if invoice_data.is_fiat_subscription():
|
||||
raise ValueError("Cannot create direct fiat subscription payments.")
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
|
@ -169,15 +173,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 payent
|
||||
# TODO: move to payment
|
||||
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}"
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
internal_payment = await update_payment(
|
||||
internal_payment, new_checking_id, conn=conn
|
||||
)
|
||||
return internal_payment
|
||||
|
||||
|
||||
|
|
@ -213,10 +217,12 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
|||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
extension=data.extension,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
external_id=data.external_id,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
|
@ -257,7 +263,9 @@ async def create_invoice(
|
|||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
extension: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
|
|
@ -339,9 +347,11 @@ async def create_invoice(
|
|||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
extension=extension,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
|
|
@ -366,10 +376,17 @@ async def update_pending_payments(wallet_id: str):
|
|||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
if payment.is_in and payment.is_expired:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment.labels.append("expired")
|
||||
await update_payment(payment, conn=conn)
|
||||
logger.info(f"invoice {payment.checking_id} expired, marked as failed")
|
||||
return payment
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
|
|
@ -509,7 +526,7 @@ async def update_wallet_balance(
|
|||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
|
|
@ -549,10 +566,9 @@ async def check_wallet_daily_withdraw_limit(
|
|||
raise ValueError("It is not allowed to spend funds from this server.")
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
since=daystart_timestamp(),
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
|
|
@ -618,23 +634,19 @@ async def check_transaction_status(
|
|||
return PaymentPendingStatus()
|
||||
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee, preimage=payment.preimage)
|
||||
|
||||
return await check_payment_status(payment)
|
||||
|
||||
|
||||
async def check_payment_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_payment_status(payment: Payment) -> PaymentStatus:
|
||||
if payment.is_internal:
|
||||
if payment.success:
|
||||
return PaymentSuccessStatus()
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee, preimage=payment.preimage)
|
||||
if payment.failed:
|
||||
return PaymentFailedStatus()
|
||||
if payment.is_in and payment.fiat_provider:
|
||||
fiat_status = await check_fiat_status(
|
||||
payment, skip_internal_payment_notifications
|
||||
)
|
||||
fiat_status = await check_fiat_status(payment)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
|
|
@ -776,13 +788,16 @@ 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)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
await _send_payment_notification_in_background(
|
||||
wallet.id, payment, conn=conn
|
||||
) # notify the sender
|
||||
await _send_payment_notification_in_background(
|
||||
internal_payment.wallet_id, internal_payment, conn=conn
|
||||
) # notify the receiver
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
await internal_invoice_queue.put(internal_payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(internal_payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
|
@ -819,14 +834,15 @@ async def _pay_external_invoice(
|
|||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
task = task_manager.create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
|
||||
f"fundingsource_pay_invoice_{checking_id}",
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(
|
||||
|
|
@ -834,26 +850,46 @@ async def _pay_external_invoice(
|
|||
)
|
||||
return payment
|
||||
|
||||
# IMPORTANT PAYMENT RULES!
|
||||
# True -> success
|
||||
# False-> failed
|
||||
# None -> pending (any ambigous payment responses MUST be set as pending)
|
||||
|
||||
# payment failed
|
||||
if (
|
||||
payment_response.checking_id is None
|
||||
or payment_response.ok is False
|
||||
or payment_response.checking_id != checking_id
|
||||
):
|
||||
if payment_response.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
if payment_response.success:
|
||||
# payment successful
|
||||
elif payment_response.success:
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
payment,
|
||||
payment_response,
|
||||
conn=conn,
|
||||
new_checking_id=payment_response.checking_id,
|
||||
)
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
logger.success(f"payment successful {payment.checking_id}")
|
||||
|
||||
# payment pending
|
||||
else:
|
||||
if (
|
||||
payment_response.checking_id
|
||||
and payment_response.checking_id != payment.checking_id
|
||||
):
|
||||
payment = await update_payment(
|
||||
payment,
|
||||
new_checking_id=payment_response.checking_id,
|
||||
conn=conn,
|
||||
)
|
||||
logger.warning(
|
||||
f"payment status unknown {payment.checking_id}: "
|
||||
f"{payment_response.error_message or 'no error message'}"
|
||||
)
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
return payment
|
||||
|
||||
|
||||
|
|
@ -861,13 +897,16 @@ async def update_payment_success_status(
|
|||
payment: Payment,
|
||||
status: PaymentStatus,
|
||||
conn: Connection | None = None,
|
||||
new_checking_id: str | None = None,
|
||||
) -> Payment:
|
||||
if status.success:
|
||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(
|
||||
payment, new_checking_id=new_checking_id, conn=conn
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
|
|
@ -1068,3 +1107,46 @@ async def _send_payment_notification_in_background(
|
|||
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_from_paid_invoices_stream(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment from paid_invoices_stream()
|
||||
Checks its status, updates its status and returns it.
|
||||
returns None if no incoming payment was found or the status is not successful
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No incoming payment found for '{checking_id}'.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
|
||||
if not status.success:
|
||||
logger.error(
|
||||
"Unexpected status response from paid_invoices_stream. Skipping update."
|
||||
)
|
||||
return None
|
||||
|
||||
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)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def fundingsource_invoice_producer() -> None:
|
||||
"""
|
||||
will collect all invoices that come directly from the backend wallet.
|
||||
|
||||
Called registered in the app startup sequence and run by taskmanager.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
payment = await update_invoice_from_paid_invoices_stream(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
task_manager.invoice_queue.put_nowait(payment)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ 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,
|
||||
|
|
@ -55,6 +56,8 @@ 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(
|
||||
|
|
@ -95,6 +98,15 @@ 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,76 +2,43 @@ import asyncio
|
|||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud import create_audit_entry
|
||||
from lnbits.core.crud.payments import get_payments_status_count
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.funding_source import get_balance_delta
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
send_payment_notification,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
if settings.notification_balance_delta_threshold_sats > 0:
|
||||
try:
|
||||
# runs by default every minute, the delta should not change that often
|
||||
await check_balance_delta_changed()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
|
||||
try:
|
||||
await check_server_balance_against_node()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % status_minutes == 0:
|
||||
try:
|
||||
await _notify_server_status()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % 60 == 0:
|
||||
try:
|
||||
# initialize the list of all extensions
|
||||
await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=True
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
minute_counter += 1
|
||||
await asyncio.sleep(60)
|
||||
async def process_next_audit_entry() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
data = await audit_queue.get()
|
||||
await create_audit_entry(data)
|
||||
|
||||
|
||||
async def _notify_server_status() -> None:
|
||||
async def refresh_extension_cache() -> None:
|
||||
# only refreshes every 10 minutes
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
|
||||
|
||||
async def notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
|
||||
status = await get_balance_delta()
|
||||
values = {
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
|
|
@ -88,76 +55,38 @@ async def _notify_server_status() -> None:
|
|||
enqueue_admin_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.warning("Payment notification error", ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await delete_expired_audit_entries()
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
# clean every hour
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
|
||||
|
||||
if sleep_time > 0:
|
||||
try:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
try:
|
||||
if (
|
||||
settings.lnbits_price_aggregator_enabled
|
||||
and settings.lnbits_price_aggregator_url
|
||||
):
|
||||
price = await btc_price_from_aggregator(currency)
|
||||
if price:
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
price,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(
|
||||
{"Aggregator": price}, max_history_size
|
||||
)
|
||||
else:
|
||||
sleep_time = 60
|
||||
await asyncio.sleep(sleep_time)
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
lnbits_rate,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
|
|
@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
|
|||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
from lnbits.task_manager import PublicTask, task_manager
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||
|
|
@ -44,11 +44,10 @@ async def api_auditor():
|
|||
name="Monitor",
|
||||
description="show the current listeners and other monitoring data",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=list[PublicTask],
|
||||
)
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
}
|
||||
async def api_monitor() -> list[PublicTask]:
|
||||
return task_manager.get_public_tasks()
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,14 @@ 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 create_user_asset
|
||||
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.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
|
|
@ -75,11 +82,7 @@ async def api_get_asset_data(
|
|||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return Response(
|
||||
content=asset.data,
|
||||
media_type=asset.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
|
||||
)
|
||||
return asset_response(asset.data, asset.mime_type, asset.name)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
|
|
@ -101,14 +104,14 @@ async def api_get_asset_thumbnail(
|
|||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return Response(
|
||||
return asset_response(
|
||||
content=(
|
||||
base64.b64decode(asset_info.thumbnail_base64)
|
||||
if asset_info.thumbnail_base64
|
||||
else b""
|
||||
),
|
||||
media_type=asset_info.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
|
||||
media_type=thumbnail_media_type(),
|
||||
filename=asset_info.name,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -172,3 +175,16 @@ 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),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from lnbits.decorators import (
|
|||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
|
|
@ -155,9 +156,20 @@ async def impersonate_user(
|
|||
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
|
||||
"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,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -178,7 +190,12 @@ async def stop_impersonate_user(
|
|||
)
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
|
||||
"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")
|
||||
|
|
@ -278,7 +295,10 @@ async def api_create_user_api_token(
|
|||
account.username, api_token_id, data.expiration_time_minutes
|
||||
)
|
||||
|
||||
acl.token_id_list.append(SimpleItem(id=api_token_id, name=data.token_name))
|
||||
expires_at = int(time()) + data.expiration_time_minutes * 60
|
||||
acl.token_id_list.append(
|
||||
SimpleItem(id=api_token_id, name=data.token_name, expires_at=expires_at)
|
||||
)
|
||||
await update_user_access_control_list(acls)
|
||||
return ApiTokenResponse(id=api_token_id, api_token=api_token)
|
||||
|
||||
|
|
@ -304,7 +324,10 @@ async def api_delete_user_api_token(
|
|||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: str | None = None
|
||||
request: Request,
|
||||
provider: str,
|
||||
user_id: str | None = None,
|
||||
auth_user_id: str | None = Depends(optional_user_id),
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
|
|
@ -312,6 +335,8 @@ 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:
|
||||
|
|
@ -332,7 +357,11 @@ 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.")
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
if provider_sso.state is None or provider_sso.state == "null":
|
||||
user_id = None
|
||||
else:
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
|
||||
|
|
@ -568,7 +597,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, email)
|
||||
return _auth_redirect_response(redirect_path, account.id, email)
|
||||
|
||||
|
||||
def _auth_success_response(
|
||||
|
|
@ -583,9 +612,20 @@ 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, max_age=max_age
|
||||
"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,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
|
|
@ -602,15 +642,28 @@ def _auth_api_token_response(
|
|||
)
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(
|
||||
usr=user_id, sub="", 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, max_age=max_age
|
||||
"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,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
|
|
@ -630,7 +683,10 @@ 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=True
|
||||
client_id,
|
||||
client_secret,
|
||||
None,
|
||||
allow_insecure_http=not settings.auth_https_only,
|
||||
)
|
||||
if (
|
||||
discovery_url
|
||||
|
|
|
|||
170
lnbits/core/views/blockexplorer_api.py
Normal file
170
lnbits/core/views/blockexplorer_api.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.services.blockexplorer import (
|
||||
address_event_to_response,
|
||||
fetch_fee_estimates,
|
||||
fetch_onchain_balance,
|
||||
fetch_recent_blocks,
|
||||
fetch_tip,
|
||||
fetch_transaction,
|
||||
fetch_utxos,
|
||||
)
|
||||
from lnbits.decorators import check_access_token, check_user_exists
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import (
|
||||
OnchainAddressEvent,
|
||||
OnchainTxEvent,
|
||||
relay_ws_queue,
|
||||
task_manager,
|
||||
)
|
||||
from lnbits.utils.electrum import (
|
||||
UTXO,
|
||||
AddressResponse,
|
||||
BlockHeader,
|
||||
BlockInfo,
|
||||
ElectrumError,
|
||||
FeeResponse,
|
||||
Transaction,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
blockexplorer_router = APIRouter(
|
||||
tags=["Block Explorer"],
|
||||
prefix="/blockexplorer/api/v1",
|
||||
)
|
||||
|
||||
|
||||
def _check_enabled() -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Block explorer is not enabled.",
|
||||
)
|
||||
|
||||
|
||||
async def _check_api_access(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> None:
|
||||
_check_enabled()
|
||||
if not settings.lnbits_blockexplorer_public_api:
|
||||
await check_user_exists(r, access_token, usr)
|
||||
|
||||
|
||||
# ---- REST ----
|
||||
|
||||
|
||||
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
|
||||
async def api_blocks() -> list[BlockInfo]:
|
||||
try:
|
||||
return await fetch_recent_blocks()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tip", dependencies=[Depends(_check_api_access)])
|
||||
async def api_tip() -> BlockHeader:
|
||||
try:
|
||||
return await fetch_tip()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/fees", dependencies=[Depends(_check_api_access)])
|
||||
async def api_fees() -> FeeResponse:
|
||||
try:
|
||||
return await fetch_fee_estimates()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tx/{txid}", dependencies=[Depends(_check_api_access)])
|
||||
async def api_tx(txid: str) -> Transaction:
|
||||
try:
|
||||
return await fetch_transaction(txid)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get(
|
||||
"/address/{address}", dependencies=[Depends(_check_api_access)]
|
||||
)
|
||||
async def api_address(address: str) -> AddressResponse:
|
||||
try:
|
||||
scripthash_from_address(address)
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
|
||||
try:
|
||||
return await fetch_onchain_balance(address)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
|
||||
async def api_utxos(address: str) -> list[UTXO]:
|
||||
try:
|
||||
scripthash_from_address(address)
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
|
||||
try:
|
||||
return await fetch_utxos(address)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
# ---- WebSocket ----
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/blocks")
|
||||
async def ws_blocks(websocket: WebSocket) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
|
||||
task_manager.register_ws_block_queue(queue)
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue)
|
||||
finally:
|
||||
task_manager.unregister_ws_block_queue(queue)
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/address/{address}")
|
||||
async def ws_address(websocket: WebSocket, address: str) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
|
||||
try:
|
||||
task_manager.register_ws_address_queue(address, queue)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=1008, reason=str(e))
|
||||
return
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
|
||||
finally:
|
||||
task_manager.unregister_ws_address_queue(address, queue)
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/tx/{txid}")
|
||||
async def ws_tx(websocket: WebSocket, txid: str) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
|
||||
task_manager.register_ws_tx_queue(txid, queue)
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
|
||||
finally:
|
||||
task_manager.unregister_ws_tx_queue(txid, queue)
|
||||
|
|
@ -4,17 +4,30 @@ 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
|
||||
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.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"])
|
||||
|
|
@ -50,6 +63,41 @@ 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}'.",
|
||||
|
|
@ -280,3 +328,382 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
|
|||
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": subscription_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()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
|
|
@ -9,7 +10,7 @@ from fastapi.requests import Request
|
|||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
from lnbits.core.crud.wallets import get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
|
|
@ -18,27 +19,54 @@ from lnbits.core.models.extensions import (
|
|||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionArchiveValidationError,
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionBackgroundPaymentGrant,
|
||||
ExtensionBackgroundPaymentGrantRequest,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionPermissionCheckRequest,
|
||||
ExtensionPermissionCheckResponse,
|
||||
ExtensionPermissionCheckResult,
|
||||
ExtensionPermissionsResponse,
|
||||
ExtensionPermissionsUpdate,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
ExtensionWalletPaymentsWatchGrant,
|
||||
ExtensionWalletPaymentsWatchGrantRequest,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
WasmRuntimeLimitsInfo,
|
||||
WasmRuntimeLimitsUpdate,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
deactivate_extension,
|
||||
get_current_wasm_invocations,
|
||||
get_valid_extension,
|
||||
get_valid_extensions,
|
||||
get_wasm_invocation_history,
|
||||
get_wasm_invocation_summary,
|
||||
install_extension,
|
||||
resolve_wasm_runtime_limits,
|
||||
stop_wasm_invocation,
|
||||
uninstall_extension,
|
||||
update_wasm_extension_runtime_limits,
|
||||
validate_wasm_runtime_limit_overrides,
|
||||
)
|
||||
from lnbits.core.wasm_ext.api.permissions import (
|
||||
validate_extension_permissions,
|
||||
validate_wasm_extension_permissions,
|
||||
)
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
|
|
@ -66,6 +94,9 @@ extension_router = APIRouter(
|
|||
prefix="/api/v1/extension",
|
||||
)
|
||||
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
|
||||
|
||||
|
||||
@extension_router.post("", dependencies=[Depends(check_admin)])
|
||||
async def api_install_extension(data: CreateExtension):
|
||||
|
|
@ -89,21 +120,39 @@ async def api_install_extension(data: CreateExtension):
|
|||
)
|
||||
|
||||
try:
|
||||
extension = await install_extension(ext_info)
|
||||
extension = await install_extension(
|
||||
ext_info,
|
||||
granted_permissions=data.permissions,
|
||||
allow_admin_policy_overrides=True,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
ext_info.clean_extension_files()
|
||||
if isinstance(exc, ExtensionArchiveValidationError):
|
||||
ext_info.zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
try:
|
||||
archive_config = ext_info.load_archive_config()
|
||||
except ValueError:
|
||||
archive_config = {}
|
||||
if archive_config.get("extension_type") == "wasm":
|
||||
ext_info.clean_wasm_extension_files()
|
||||
else:
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, AssertionError)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else f"Failed to install extension '{ext_info.id}'."
|
||||
f"({ext_info.installed_version})."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
status_code=(
|
||||
HTTPStatus.BAD_REQUEST
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
|
|
@ -124,6 +173,106 @@ async def api_install_extension(data: CreateExtension):
|
|||
) from exc
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations/current",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_current_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
return get_current_wasm_invocations(extension_id=extension_id)
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[WasmInvocation]:
|
||||
return await get_wasm_invocation_history(
|
||||
extension_id=extension_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations/stats",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_invocation_stats(
|
||||
extension_id: str | None = None,
|
||||
hours: int = 24,
|
||||
) -> WasmInvocationStats:
|
||||
return await get_wasm_invocation_summary(extension_id=extension_id, hours=hours)
|
||||
|
||||
|
||||
@extension_router.post(
|
||||
"/wasm/invocations/{invocation_id}/stop",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_stop_wasm_invocation(invocation_id: str) -> SimpleStatus:
|
||||
await stop_wasm_invocation(invocation_id, reason="Stopped by admin.")
|
||||
return SimpleStatus(success=True, message="WASM invocation stop requested.")
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/runtime-limits/extensions",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_runtime_limit_extensions() -> list[WasmRuntimeLimitsInfo]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
return [
|
||||
WasmRuntimeLimitsInfo(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
active=extension.active,
|
||||
wasm_runtime_limits=validate_wasm_runtime_limit_overrides(
|
||||
extension.wasm_runtime_limits,
|
||||
strict=False,
|
||||
),
|
||||
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
|
||||
)
|
||||
for extension in installed_extensions
|
||||
if extension.is_wasm
|
||||
]
|
||||
|
||||
|
||||
@extension_router.put(
|
||||
"/wasm/runtime-limits/{ext_id}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_update_wasm_runtime_limits(
|
||||
ext_id: str,
|
||||
data: WasmRuntimeLimitsUpdate,
|
||||
) -> WasmRuntimeLimitsInfo:
|
||||
try:
|
||||
wasm_runtime_limits = await update_wasm_extension_runtime_limits(
|
||||
ext_id, data.limits
|
||||
)
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
extension.wasm_runtime_limits = wasm_runtime_limits
|
||||
return WasmRuntimeLimitsInfo(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
active=extension.active,
|
||||
wasm_runtime_limits=wasm_runtime_limits,
|
||||
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/details")
|
||||
async def api_extension_details(
|
||||
ext_id: str,
|
||||
|
|
@ -247,6 +396,250 @@ async def api_disable_extension(
|
|||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/background-payment")
|
||||
async def api_grant_background_payment_permission(
|
||||
ext_id: str,
|
||||
data: ExtensionBackgroundPaymentGrantRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request background payments.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Background payments are not allowed from shared wallets.",
|
||||
)
|
||||
if not wallet.can_send_payments:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This wallet cannot send payments.",
|
||||
)
|
||||
|
||||
permissions = user_ext.permissions or {}
|
||||
grant = data.to_grant(
|
||||
_user_permission_grant_id_for_wallet(
|
||||
permissions,
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
data.wallet_id,
|
||||
)
|
||||
)
|
||||
background_grants = [
|
||||
existing
|
||||
for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, [])
|
||||
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
|
||||
]
|
||||
background_grants.append(json.loads(grant.json()))
|
||||
permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants
|
||||
user_ext.permissions = permissions
|
||||
await update_user_extension(user_ext)
|
||||
return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant}
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/wallet-payments-watch")
|
||||
async def api_grant_wallet_payments_watch_permission(
|
||||
ext_id: str,
|
||||
data: ExtensionWalletPaymentsWatchGrantRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request wallet payment watch access.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
permissions = user_ext.permissions or {}
|
||||
grant = data.to_grant(
|
||||
_user_permission_grant_id_for_wallet(
|
||||
permissions,
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
data.wallet_id,
|
||||
)
|
||||
)
|
||||
watch_grants = [
|
||||
existing
|
||||
for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, [])
|
||||
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
|
||||
]
|
||||
watch_grants.append(json.loads(grant.json()))
|
||||
permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants
|
||||
user_ext.permissions = permissions
|
||||
await update_user_extension(user_ext)
|
||||
return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant}
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/permissions")
|
||||
async def api_get_extension_permissions(
|
||||
ext_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> ExtensionPermissionsResponse:
|
||||
installed_ext = await _require_active_wasm_extension(ext_id)
|
||||
extension_permissions = validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
return ExtensionPermissionsResponse(
|
||||
extension_permissions=extension_permissions,
|
||||
user_permissions=_safe_user_extension_permissions(
|
||||
user_ext.permissions if user_ext else {}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/permissions", dependencies=[Depends(check_admin)])
|
||||
async def api_update_extension_permissions(
|
||||
ext_id: str,
|
||||
data: ExtensionPermissionsUpdate,
|
||||
) -> ExtensionPermissionsResponse:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not installed."
|
||||
)
|
||||
if not installed_ext.is_wasm:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
|
||||
)
|
||||
|
||||
try:
|
||||
extension_config = _load_installed_extension_config(installed_ext)
|
||||
installed_ext.permissions = validate_wasm_extension_permissions(
|
||||
installed_ext,
|
||||
data.permissions,
|
||||
extension_config,
|
||||
allow_admin_policy_overrides=True,
|
||||
)
|
||||
await update_installed_extension(installed_ext)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return ExtensionPermissionsResponse(
|
||||
extension_permissions=validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@extension_router.delete("/{ext_id}/permissions/user/{grant_id}")
|
||||
async def api_delete_user_extension_permission(
|
||||
ext_id: str,
|
||||
grant_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> SimpleStatus:
|
||||
await _require_active_wasm_extension(ext_id)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext:
|
||||
return SimpleStatus(success=True, message="Permission grant removed.")
|
||||
|
||||
user_ext.permissions = _remove_user_permission_grant(
|
||||
user_ext.permissions or {}, grant_id
|
||||
)
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message="Permission grant removed.")
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/check")
|
||||
async def api_check_extension_permissions(
|
||||
ext_id: str,
|
||||
data: ExtensionPermissionCheckRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> ExtensionPermissionCheckResponse:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
results: list[ExtensionPermissionCheckResult] = []
|
||||
for permission in data.permissions:
|
||||
if permission.id not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request '{permission.id}'.",
|
||||
)
|
||||
if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION:
|
||||
results.append(
|
||||
await _check_background_payment_permission(
|
||||
account_id.id,
|
||||
user_ext.permissions or {},
|
||||
permission.grant,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION:
|
||||
results.append(
|
||||
await _check_wallet_payments_watch_permission(
|
||||
account_id.id,
|
||||
user_ext.permissions or {},
|
||||
permission.grant,
|
||||
)
|
||||
)
|
||||
continue
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Unsupported permission check '{permission.id}'.",
|
||||
)
|
||||
|
||||
return ExtensionPermissionCheckResponse(permissions=results)
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
|
|
@ -288,7 +681,6 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||
|
||||
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise HTTPException(
|
||||
|
|
@ -332,6 +724,10 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
|
|||
extension_releases: list[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
for release in extension_releases:
|
||||
release.permissions = validate_extension_permissions(
|
||||
ext_id, release.permissions
|
||||
)
|
||||
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext:
|
||||
|
|
@ -456,11 +852,19 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
|||
if not config:
|
||||
return {}
|
||||
|
||||
permissions = validate_extension_permissions(config.name, config.permissions)
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
"extension_type": config.extension_type,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
|
|
@ -532,9 +936,11 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
|||
)
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||
installable_exts.sort(key=lambda e: e.id)
|
||||
installed_exts_by_id = {e.id: e for e in installed_exts}
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
installed_ext = installed_exts_by_id.get(e.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
installed_release = installed_ext.meta.installed_release
|
||||
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
|
||||
|
|
@ -555,46 +961,60 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
|||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
extension_data = []
|
||||
for ext in installable_exts:
|
||||
installed_ext = installed_exts_by_id.get(ext.id)
|
||||
is_wasm = installed_ext.is_wasm if installed_ext else ext.is_wasm
|
||||
icon = wasm_extension_icon_url(ext.id) if is_wasm else ext.icon
|
||||
permissions = (
|
||||
validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
if installed_ext
|
||||
else []
|
||||
)
|
||||
extension_data.append(
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"categories": ext.meta.categories if ext.meta else [],
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"isWasm": is_wasm,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
)
|
||||
return extension_data
|
||||
|
||||
|
||||
|
|
@ -642,3 +1062,193 @@ async def create_extension_review(
|
|||
resp.raise_for_status()
|
||||
payment_request = resp.json()
|
||||
return ExtensionReviewPaymentRequest(**payment_request)
|
||||
|
||||
|
||||
def _load_installed_extension_config(extension: InstallableExtension) -> dict:
|
||||
ext_dir = extension.wasm_ext_dir if extension.is_wasm else extension.ext_dir
|
||||
config_path = ext_dir / "config.json"
|
||||
if not config_path.is_file():
|
||||
raise ValueError(f"Extension '{extension.id}' config file is missing.")
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as config_file:
|
||||
config = json.load(config_file)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot read extension config for '{extension.id}'.") from exc
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError(f"Extension '{extension.id}' config file is invalid.")
|
||||
return config
|
||||
|
||||
|
||||
async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
if not installed_ext.is_wasm:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
|
||||
)
|
||||
return installed_ext
|
||||
|
||||
|
||||
def _safe_user_extension_permissions(permissions: dict | None) -> dict:
|
||||
safe_permissions: dict[str, list[dict]] = {}
|
||||
for permission_id, grants in (permissions or {}).items():
|
||||
if not isinstance(permission_id, str) or not isinstance(grants, list):
|
||||
continue
|
||||
safe_grants = [
|
||||
grant
|
||||
for grant in grants
|
||||
if isinstance(grant, dict) and isinstance(grant.get("id"), str)
|
||||
]
|
||||
if safe_grants:
|
||||
safe_permissions[permission_id] = safe_grants
|
||||
return safe_permissions
|
||||
|
||||
|
||||
def _user_permission_grant_id_for_wallet(
|
||||
permissions: dict, permission_id: str, wallet_id: str
|
||||
) -> str | None:
|
||||
grants = permissions.get(permission_id)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
|
||||
for grant in grants:
|
||||
if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id:
|
||||
continue
|
||||
grant_id = grant.get("id")
|
||||
return grant_id if isinstance(grant_id, str) and grant_id else None
|
||||
return None
|
||||
|
||||
|
||||
def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict:
|
||||
updated_permissions = dict(permissions or {})
|
||||
for permission_id, grants in list(updated_permissions.items()):
|
||||
if not isinstance(grants, list):
|
||||
continue
|
||||
|
||||
remaining_grants = [
|
||||
grant
|
||||
for grant in grants
|
||||
if not isinstance(grant, dict) or grant.get("id") != grant_id
|
||||
]
|
||||
if remaining_grants:
|
||||
updated_permissions[permission_id] = remaining_grants
|
||||
else:
|
||||
updated_permissions.pop(permission_id, None)
|
||||
return updated_permissions
|
||||
|
||||
|
||||
async def _check_background_payment_permission(
|
||||
account_id: str,
|
||||
permissions: dict,
|
||||
grant_data: dict,
|
||||
) -> ExtensionPermissionCheckResult:
|
||||
try:
|
||||
data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Background payments are not allowed from shared wallets.",
|
||||
)
|
||||
if not wallet.can_send_payments:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This wallet cannot send payments.",
|
||||
)
|
||||
|
||||
requested_grant = data.to_grant()
|
||||
existing_grant = _find_background_payment_grant(
|
||||
permissions, requested_grant.wallet_id
|
||||
)
|
||||
covered = (
|
||||
existing_grant is not None
|
||||
and existing_grant.enabled
|
||||
and existing_grant.max_amount >= requested_grant.max_amount
|
||||
and _background_destination_policy_covers(
|
||||
existing_grant.destination_policy, requested_grant.destination_policy
|
||||
)
|
||||
)
|
||||
grant = existing_grant if covered and existing_grant else requested_grant
|
||||
return ExtensionPermissionCheckResult(
|
||||
id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
approved=covered,
|
||||
grant=json.loads(grant.json()),
|
||||
)
|
||||
|
||||
|
||||
async def _check_wallet_payments_watch_permission(
|
||||
account_id: str,
|
||||
permissions: dict,
|
||||
grant_data: dict,
|
||||
) -> ExtensionPermissionCheckResult:
|
||||
try:
|
||||
data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id)
|
||||
covered = bool(existing_grant and existing_grant.enabled)
|
||||
grant = existing_grant if covered and existing_grant else data.to_grant()
|
||||
return ExtensionPermissionCheckResult(
|
||||
id=WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
approved=covered,
|
||||
grant=json.loads(grant.json()),
|
||||
)
|
||||
|
||||
|
||||
def _find_background_payment_grant(
|
||||
permissions: dict, wallet_id: str
|
||||
) -> ExtensionBackgroundPaymentGrant | None:
|
||||
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
def _find_wallet_payments_watch_grant(
|
||||
permissions: dict, wallet_id: str
|
||||
) -> ExtensionWalletPaymentsWatchGrant | None:
|
||||
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
def _background_destination_policy_covers(
|
||||
existing: ExtensionBackgroundPaymentDestinationPolicy,
|
||||
requested: ExtensionBackgroundPaymentDestinationPolicy,
|
||||
) -> bool:
|
||||
return (
|
||||
existing == requested
|
||||
or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,35 @@ 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 StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat import RevolutWallet, 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,
|
||||
|
|
@ -22,6 +40,54 @@ 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,
|
||||
|
|
|
|||
|
|
@ -185,10 +185,15 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
|||
@generic_router.get("/wallets")
|
||||
@generic_router.get("/account")
|
||||
@generic_router.get("/extensions")
|
||||
@generic_router.get("/blockexplorer")
|
||||
@generic_router.get("/blockexplorer/{resource_type}/{resource}")
|
||||
@generic_router.get("/users", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/audit", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/node", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm/limits", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm/{ext_id}", dependencies=admin_ui_checks)
|
||||
@generic_router.get(
|
||||
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
|
|
@ -196,11 +201,13 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
|||
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
request: Request,
|
||||
ext_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
"base.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
},
|
||||
|
|
@ -211,7 +218,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, "index.html", {"public": True})
|
||||
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
)
|
||||
from lnurl import (
|
||||
LnurlAuthResponse,
|
||||
|
|
@ -18,6 +21,7 @@ from lnurl import execute_login as lnurlauth
|
|||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import LnurlResponseModel
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
|
|
@ -27,13 +31,50 @@ from lnbits.decorators import (
|
|||
require_base_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.settings import RedirectPath, settings
|
||||
|
||||
from ..services import fetch_lnurl_pay_request, pay_invoice
|
||||
from ..services.lightning_address import (
|
||||
wallet_lightning_address_callback,
|
||||
wallet_lightning_address_response,
|
||||
)
|
||||
|
||||
lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/.well-known/lnurlp/{username}",
|
||||
name="lnurl.api_wallet_lightning_address_response",
|
||||
)
|
||||
async def api_wallet_lightning_address_response(
|
||||
username: str, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
if settings.lnbits_ln_address_mode in ["extension_first", "extension_only"]:
|
||||
req_headers = request["headers"] if "headers" in request else []
|
||||
redirect = settings.find_extension_redirect(request.url.path, req_headers)
|
||||
if redirect:
|
||||
resp = await _check_extension_well_known(redirect, request)
|
||||
if resp and resp.ok:
|
||||
return resp
|
||||
|
||||
if settings.lnbits_ln_address_mode == "extension_only":
|
||||
return LnurlErrorResponse(
|
||||
reason="Lightning addresses are not supported on this instance."
|
||||
)
|
||||
|
||||
return await wallet_lightning_address_response(username, request)
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurl/wallet/{username}/cb",
|
||||
name="lnurl.api_wallet_lightning_address_callback",
|
||||
)
|
||||
async def api_wallet_lightning_address_callback(
|
||||
username: str, request: Request, amount: int = Query(...)
|
||||
) -> LnurlErrorResponse | Any:
|
||||
return await wallet_lightning_address_callback(username, request, amount)
|
||||
|
||||
|
||||
async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
try:
|
||||
if "@" in lnurl: # lower case lightning addresses
|
||||
|
|
@ -137,3 +178,32 @@ async def api_payments_pay_lnurl(
|
|||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def _check_extension_well_known(
|
||||
redirect: RedirectPath, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse | None:
|
||||
target_path = redirect.new_path_from(request.url.path)
|
||||
|
||||
transport = httpx.ASGITransport(app=request.app)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url=str(request.base_url),
|
||||
) as client:
|
||||
response = await client.get(
|
||||
target_path,
|
||||
headers={"accept": "application/json"},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
try:
|
||||
return LnurlPayResponse.parse_obj(response_data)
|
||||
except ValidationError:
|
||||
return LnurlErrorResponse.parse_obj(response_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to fetch LNURL response Extension redirect {target_path}: {exc}"
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from secrets import token_hex
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -14,6 +15,7 @@ from lnurl import url_decode
|
|||
from lnbits import bolt11
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payment_count_stats,
|
||||
get_wallet_payment_total_breakdown,
|
||||
get_wallets_stats,
|
||||
update_payment,
|
||||
)
|
||||
|
|
@ -31,13 +33,16 @@ from lnbits.core.models import (
|
|||
PaymentDailyStats,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentTotalBreakdown,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
SimpleStatus,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.users import AccountId, UserLabel
|
||||
from lnbits.core.models.wallets import BaseWalletTypeInfo
|
||||
from lnbits.core.services.users import update_user_account
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
|
|
@ -50,6 +55,7 @@ from lnbits.decorators import (
|
|||
from lnbits.helpers import (
|
||||
filter_dict_keys,
|
||||
generate_filter_params_openapi,
|
||||
is_valid_label,
|
||||
)
|
||||
from lnbits.wallets.base import InvoiceResponse
|
||||
|
||||
|
|
@ -130,6 +136,17 @@ async def api_payments_counting_stats(
|
|||
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/breakdown",
|
||||
name="Get wallet payment total breakdown",
|
||||
response_model=list[PaymentTotalBreakdown],
|
||||
)
|
||||
async def api_payments_total_breakdown(
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
):
|
||||
return await get_wallet_payment_total_breakdown(key_info.wallet.id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/wallets",
|
||||
name="Get payments history for all users",
|
||||
|
|
@ -263,6 +280,7 @@ 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
|
||||
|
||||
|
|
@ -289,13 +307,60 @@ async def api_update_payment_labels(
|
|||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
|
||||
|
||||
# only keep labels that belong to the user
|
||||
user_label_names = [label.name for label in account.extra.labels]
|
||||
payment.labels = [label for label in data.labels if label in user_label_names]
|
||||
updated_account = False
|
||||
for label_name in data.labels:
|
||||
if label_name not in user_label_names:
|
||||
if not is_valid_label(label_name):
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Invalid label name: '{label_name}'."
|
||||
)
|
||||
account.extra.labels.append(
|
||||
UserLabel(name=label_name, color=f"#{token_hex(3)}")
|
||||
)
|
||||
user_label_names.append(label_name)
|
||||
updated_account = True
|
||||
|
||||
if updated_account:
|
||||
await update_user_account(account)
|
||||
|
||||
payment.labels = data.labels
|
||||
await update_payment(payment)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from lnbits.core.services import (
|
|||
update_user_extensions,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.services.lightning_address import set_wallet_lightning_address
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.helpers import (
|
||||
|
|
@ -158,10 +159,6 @@ async def api_update_user(
|
|||
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 user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
|
@ -173,6 +170,11 @@ async def api_users_delete_user(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Only super_user can delete admin user.",
|
||||
)
|
||||
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
|
||||
await delete_account(user_id)
|
||||
return SimpleStatus(success=True, message="User deleted.")
|
||||
|
||||
|
|
@ -279,6 +281,34 @@ async def api_users_create_user_wallet(
|
|||
return wallet
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/user/{user_id}/wallet/{wallet}/lightning-address",
|
||||
name="Set wallet Lightning Address",
|
||||
)
|
||||
async def api_users_set_wallet_lightning_address(
|
||||
user_id: str,
|
||||
wallet: str,
|
||||
lightning_address: str = Body(..., embed=True),
|
||||
) -> Wallet:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet does not exist.",
|
||||
)
|
||||
if user_id != wal.user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Wallet does not belong to user.",
|
||||
)
|
||||
return await set_wallet_lightning_address(
|
||||
wallet=wal,
|
||||
local_part=lightning_address,
|
||||
allow_blacklisted=True,
|
||||
charge=False,
|
||||
)
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from lnbits.core.models.wallets import (
|
|||
WalletSharePermission,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.core.services.lightning_address import set_wallet_lightning_address
|
||||
from lnbits.core.services.wallets import (
|
||||
create_lightning_shared_wallet,
|
||||
delete_wallet_share,
|
||||
|
|
@ -38,6 +39,7 @@ from lnbits.decorators import (
|
|||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
delete_wallet,
|
||||
|
|
@ -164,6 +166,7 @@ async def api_update_wallet(
|
|||
color: str | None = Body(None),
|
||||
currency: str | None = Body(None),
|
||||
pinned: bool | None = Body(None),
|
||||
lightning_address: str | None = Body(None),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(key_info.wallet.id)
|
||||
|
|
@ -175,6 +178,20 @@ async def api_update_wallet(
|
|||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
|
||||
if lightning_address and lightning_address != wallet.lightning_address:
|
||||
if not settings.lnbits_allow_custom_wallet_lightning_addresses:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Users cannot specify Lightning Addresses.",
|
||||
)
|
||||
# too much logic here
|
||||
wallet = await set_wallet_lightning_address(
|
||||
wallet=wallet,
|
||||
local_part=lightning_address,
|
||||
charge=True,
|
||||
)
|
||||
return wallet
|
||||
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
from fastapi import APIRouter, WebSocket
|
||||
from fastapi import APIRouter, WebSocket, status
|
||||
|
||||
from lnbits.core.crud import get_installed_extension
|
||||
from lnbits.core.wasm_ext.api.websockets import wasm_extension_websocket_hub
|
||||
|
||||
from ..services import websocket_manager
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
extension_websocket_router = APIRouter(
|
||||
prefix="/api/v1/ext/ws",
|
||||
tags=["Extension Websocket"],
|
||||
)
|
||||
|
||||
|
||||
@websocket_router.websocket("/{item_id}")
|
||||
|
|
@ -11,6 +18,35 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
|
|||
await websocket_manager.listen(conn)
|
||||
|
||||
|
||||
@extension_websocket_router.websocket("/{ext_id}/{item_id}")
|
||||
async def extension_websocket_connect(
|
||||
websocket: WebSocket,
|
||||
ext_id: str,
|
||||
item_id: str,
|
||||
) -> None:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
installed_permission_ids = (
|
||||
{permission.id for permission in installed_ext.permissions or []}
|
||||
if installed_ext
|
||||
else set()
|
||||
)
|
||||
if (
|
||||
not installed_ext
|
||||
or not installed_ext.active
|
||||
or not installed_ext.is_wasm
|
||||
or "websocket.subscribe" not in installed_permission_ids
|
||||
):
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
try:
|
||||
conn = await wasm_extension_websocket_hub.connect(ext_id, item_id, websocket)
|
||||
except ValueError:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
await wasm_extension_websocket_hub.listen(conn)
|
||||
|
||||
|
||||
@websocket_router.post("/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
|
|
|
|||
24
lnbits/core/wasm_ext/__init__.py
Normal file
24
lnbits/core/wasm_ext/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from .api.host import ExtensionHostAPI
|
||||
from .api.models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .api.registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .api.runtime import ExtensionAPIHost
|
||||
from .wasm.loader import WasmExtension
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"WasmExtension",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
22
lnbits/core/wasm_ext/api/__init__.py
Normal file
22
lnbits/core/wasm_ext/api/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .runtime import ExtensionAPIHost
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
124
lnbits/core/wasm_ext/api/background_payments.py
Normal file
124
lnbits/core/wasm_ext/api/background_payments.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extension
|
||||
from lnbits.core.crud.payments import check_internal
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models.extensions import (
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionBackgroundPaymentGrant,
|
||||
)
|
||||
from lnbits.core.models.wallets import Wallet
|
||||
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
|
||||
|
||||
|
||||
async def background_payment_extra(
|
||||
*,
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
payment_request: str,
|
||||
amount_msat: int,
|
||||
) -> dict[str, Any]:
|
||||
grant = await _background_payment_grant(extension_id, wallet, amount_msat)
|
||||
await _check_destination_policy(extension_id, wallet, grant, payment_request)
|
||||
|
||||
return {
|
||||
"tag": extension_id,
|
||||
"extension": extension_id,
|
||||
"background_payment": True,
|
||||
"background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
"background_wallet_id": wallet.source_wallet_id,
|
||||
"background_destination_policy": grant.destination_policy.value,
|
||||
}
|
||||
|
||||
|
||||
def invoice_amount_msat(payment_request: str) -> int:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
amount_msat = int(invoice.amount_msat or 0)
|
||||
if amount_msat <= 0:
|
||||
raise PermissionError("Background payments require an invoice amount.")
|
||||
return amount_msat
|
||||
|
||||
|
||||
async def _background_payment_grant(
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
amount_msat: int,
|
||||
) -> ExtensionBackgroundPaymentGrant:
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
_deny(extension_id, wallet, amount_msat, "shared wallet")
|
||||
if not wallet.can_send_payments:
|
||||
_deny(extension_id, wallet, amount_msat, "wallet cannot send payments")
|
||||
|
||||
user_extension = await get_user_extension(wallet.user, extension_id)
|
||||
if not user_extension or not user_extension.active:
|
||||
_deny(extension_id, wallet, amount_msat, "extension disabled for user")
|
||||
|
||||
permissions = user_extension.permissions or {}
|
||||
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
_deny(extension_id, wallet, amount_msat, "missing background payment grant")
|
||||
|
||||
grant = _find_wallet_grant(grants, wallet.id)
|
||||
if not grant:
|
||||
_deny(extension_id, wallet, amount_msat, "missing wallet background grant")
|
||||
if not grant.enabled:
|
||||
_deny(extension_id, wallet, amount_msat, "background grant disabled")
|
||||
if amount_msat > grant.max_amount * 1000:
|
||||
_deny(extension_id, wallet, amount_msat, "payment exceeds max amount")
|
||||
return grant
|
||||
|
||||
|
||||
def _find_wallet_grant(
|
||||
grants: list[Any], wallet_id: str
|
||||
) -> ExtensionBackgroundPaymentGrant | None:
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
async def _check_destination_policy(
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
grant: ExtensionBackgroundPaymentGrant,
|
||||
payment_request: str,
|
||||
) -> None:
|
||||
if (
|
||||
grant.destination_policy
|
||||
== ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
|
||||
):
|
||||
return
|
||||
|
||||
payment_hash = bolt11_decode(payment_request).payment_hash
|
||||
internal_payment = await check_internal(payment_hash)
|
||||
if not internal_payment:
|
||||
_deny(extension_id, wallet, 0, "external destination not allowed")
|
||||
|
||||
destination_wallet = await get_wallet(internal_payment.wallet_id)
|
||||
if not destination_wallet or destination_wallet.user != wallet.user:
|
||||
_deny(extension_id, wallet, 0, "destination wallet is not owned by user")
|
||||
|
||||
|
||||
def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn:
|
||||
logger.warning(
|
||||
"WASM extension '{}' denied background payment from wallet '{}', "
|
||||
"user '{}', amount_msat '{}': {}.",
|
||||
extension_id,
|
||||
wallet.id,
|
||||
wallet.user,
|
||||
amount_msat,
|
||||
reason,
|
||||
)
|
||||
raise PermissionError(reason)
|
||||
1000
lnbits/core/wasm_ext/api/host.py
Normal file
1000
lnbits/core/wasm_ext/api/host.py
Normal file
File diff suppressed because it is too large
Load diff
70
lnbits/core/wasm_ext/api/lnurl.py
Normal file
70
lnbits/core/wasm_ext/api/lnurl.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from lnurl import LnAddress, Lnurl
|
||||
|
||||
|
||||
def normalize_lnurl(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized.lower().startswith("lightning:"):
|
||||
normalized = normalized[len("lightning:") :]
|
||||
if "@" in normalized:
|
||||
normalized = normalized.lower()
|
||||
if not normalized:
|
||||
raise ValueError("LNURL is required.")
|
||||
return normalized
|
||||
|
||||
|
||||
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
|
||||
normalized = normalize_lnurl(value)
|
||||
if "@" in normalized:
|
||||
return LnAddress(normalized)
|
||||
return Lnurl(normalized)
|
||||
|
||||
|
||||
def lnurl_payment_amount_for_core(amount: float) -> int:
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount must be greater than zero.")
|
||||
return round(amount * 1000)
|
||||
|
||||
|
||||
def lnurl_payment_unit_for_core(currency: str) -> str:
|
||||
unit = currency.strip().lower()
|
||||
if not unit:
|
||||
raise ValueError("Currency is required.")
|
||||
if unit in {"sat", "sats"}:
|
||||
return "sat"
|
||||
return unit.upper()
|
||||
|
||||
|
||||
def lnurl_pay_response_metadata_json(response: Any) -> str:
|
||||
metadata = getattr(response, "metadata", None)
|
||||
if metadata is None:
|
||||
return "[]"
|
||||
|
||||
metadata_list = getattr(metadata, "list", None)
|
||||
try:
|
||||
if callable(metadata_list):
|
||||
return json.dumps(metadata_list())
|
||||
return json.dumps(metadata)
|
||||
except TypeError:
|
||||
return json.dumps(str(metadata))
|
||||
|
||||
|
||||
def lnurl_pay_response_text(response: Any) -> str:
|
||||
description = getattr(response, "description", None)
|
||||
if description is not None:
|
||||
return str(description)
|
||||
|
||||
metadata = getattr(response, "metadata", None)
|
||||
text = getattr(metadata, "text", None)
|
||||
return str(text) if text is not None else ""
|
||||
|
||||
|
||||
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
|
||||
value = getattr(response, snake_name, None)
|
||||
if value is None:
|
||||
value = getattr(response, camel_name, 0)
|
||||
return int(value or 0)
|
||||
440
lnbits/core/wasm_ext/api/models.py
Normal file
440
lnbits/core/wasm_ext/api/models.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, root_validator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethodExport:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethod:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
python_name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
request_model: type[BaseModel]
|
||||
response_model: type[BaseModel]
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
@property
|
||||
def sdk_qualified_name(self) -> str:
|
||||
return f"{self.namespace}.{self.sdk_name}"
|
||||
|
||||
|
||||
class EmptyRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class StorageGetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageGetResponse(BaseModel):
|
||||
data_json: str | None = None
|
||||
|
||||
|
||||
class StorageSetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
|
||||
class StorageSetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class StorageAppendPublicRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
source_id: str = Field(..., min_length=1, max_length=512)
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
|
||||
class StorageAppendPublicResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class StoragePaginatedRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
filters: dict[str, Any] = Field(default_factory=dict)
|
||||
search: str | None = Field(None, max_length=256)
|
||||
search_fields: list[str] = Field(default_factory=list)
|
||||
sort_by: str | None = Field(None, min_length=1, max_length=128)
|
||||
descending: bool = False
|
||||
limit: int = Field(25, ge=1, le=1000)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
filters_json = values.get("filters_json")
|
||||
if filters_json is not None and "filters" not in values:
|
||||
values["filters"] = json.loads(filters_json)
|
||||
|
||||
search_fields_json = values.get("search_fields_json")
|
||||
if search_fields_json is not None and "search_fields" not in values:
|
||||
values["search_fields"] = json.loads(search_fields_json)
|
||||
|
||||
if values.get("sort_by") == "":
|
||||
values["sort_by"] = None
|
||||
return values
|
||||
|
||||
|
||||
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
|
||||
source_id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StoragePaginatedResponse(BaseModel):
|
||||
rows_json: str = "[]"
|
||||
total: int = 0
|
||||
|
||||
|
||||
class WebsocketPublishRequest(BaseModel):
|
||||
item_id: str = Field(..., min_length=1, max_length=128)
|
||||
data: Any = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data = values.get("data")
|
||||
try:
|
||||
encoded = json.dumps(data, separators=(",", ":"))
|
||||
except TypeError as exc:
|
||||
raise ValueError("websocket data must be JSON serializable.") from exc
|
||||
if len(encoded.encode()) > 65536:
|
||||
raise ValueError("websocket data must not exceed 65536 bytes.")
|
||||
values["data"] = data
|
||||
return values
|
||||
|
||||
@property
|
||||
def data_json(self) -> str:
|
||||
return json.dumps(self.data, separators=(",", ":"))
|
||||
|
||||
|
||||
class WebsocketPublishResponse(BaseModel):
|
||||
sent: bool = True
|
||||
|
||||
|
||||
class StorageDeleteRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageDeleteResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class CreateInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field("sat", min_length=1, max_length=8)
|
||||
memo: str = Field(..., max_length=512)
|
||||
tag: str = Field(..., min_length=1, max_length=64)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateInvoicePublicRequest(BaseModel):
|
||||
source_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
description="The source ID (entry id) of the wallet to create the invoice for.",
|
||||
)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
memo: str = Field("", max_length=512)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator
|
||||
def validate_extra_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
extra = values.get("extra") or {}
|
||||
try:
|
||||
encoded = json.dumps(extra, separators=(",", ":"))
|
||||
except TypeError as exc:
|
||||
raise ValueError("extra must be JSON serializable.") from exc
|
||||
if len(encoded.encode()) > 4096:
|
||||
raise ValueError("extra must not exceed 4096 bytes.")
|
||||
values["extra"] = extra
|
||||
return values
|
||||
|
||||
|
||||
class CreateInvoiceResponse(BaseModel):
|
||||
payment_hash: str
|
||||
payment_request: str
|
||||
checking_id: str
|
||||
|
||||
|
||||
class UserWalletSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class ListUserWalletsResponse(BaseModel):
|
||||
wallets: list[UserWalletSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WalletBalanceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WalletBalanceResponse(BaseModel):
|
||||
wallet_id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
balance_msat: int
|
||||
balance_sat: int
|
||||
withdrawable_msat: int
|
||||
withdrawable_sat: int
|
||||
fee_reserve_msat: int
|
||||
fee_reserve_sat: int
|
||||
can_send_payments: bool
|
||||
|
||||
|
||||
class PayInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
payment_request: str = Field(..., min_length=1, max_length=8192)
|
||||
max_sat: int | None = Field(None, gt=0)
|
||||
description: str = Field("", max_length=512)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayLnurlRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
lnurl: str = Field(..., min_length=1, max_length=2048)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field("sat", min_length=1, max_length=8)
|
||||
comment: str | None = Field(None, max_length=512)
|
||||
description: str = Field("", max_length=512)
|
||||
max_sat: int | None = Field(None, gt=0)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoiceResponse(BaseModel):
|
||||
ok: bool = True
|
||||
error: str | None = None
|
||||
checking_id: str | None = None
|
||||
payment_hash: str | None = None
|
||||
status: str | None = None
|
||||
amount_msat: int = 0
|
||||
fee_msat: int = 0
|
||||
pending: bool = False
|
||||
success: bool = False
|
||||
|
||||
|
||||
class HttpRequest(BaseModel):
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = values.get("headers") or {}
|
||||
if len(headers) > 32:
|
||||
raise ValueError("headers must not contain more than 32 entries.")
|
||||
for key, value in headers.items():
|
||||
if len(key) > 128 or len(value) > 4096:
|
||||
raise ValueError("headers are too large.")
|
||||
values["headers"] = headers
|
||||
return values
|
||||
|
||||
|
||||
class HttpResponse(BaseModel):
|
||||
status_code: int
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str = ""
|
||||
|
||||
|
||||
class ExtensionApiRequest(BaseModel):
|
||||
extension_id: str = Field(..., min_length=1, max_length=128)
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
path: str = Field(..., min_length=1, max_length=2048)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
|
||||
class CurrencyListResponse(BaseModel):
|
||||
currencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CurrencyRateRequest(BaseModel):
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class CurrencyRateResponse(BaseModel):
|
||||
rate: float
|
||||
price: float
|
||||
|
||||
|
||||
class CurrencyConvertRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
from_currency: str = Field(..., alias="from", min_length=1, max_length=8)
|
||||
to: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class CurrencyConvertResponse(BaseModel):
|
||||
amounts: list[tuple[str, float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FiatToSatsRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class FiatToSatsResponse(BaseModel):
|
||||
amount_sat: int
|
||||
|
||||
|
||||
class SatsToFiatRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class SatsToFiatResponse(BaseModel):
|
||||
amount: float
|
||||
|
||||
|
||||
class LnurlResolveRequest(BaseModel):
|
||||
lnurl: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LnurlResolveResponse(BaseModel):
|
||||
lnurl: str
|
||||
domain: str | None = None
|
||||
description: str = ""
|
||||
min_sendable_msat: int
|
||||
max_sendable_msat: int
|
||||
comment_allowed: int = 0
|
||||
fixed: bool = False
|
||||
image: str | None = None
|
||||
metadata_json: str = "[]"
|
||||
|
||||
|
||||
class ServerHealthResponse(BaseModel):
|
||||
server_time: int
|
||||
up_time: str
|
||||
|
||||
|
||||
class Bolt11Request(BaseModel):
|
||||
bolt11: str = Field(..., min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class DecodeInvoiceResponse(BaseModel):
|
||||
valid: bool = True
|
||||
payment_hash: str | None = None
|
||||
amount_msat: int | None = None
|
||||
expiry: int | None = None
|
||||
expires_at: int | None = None
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class ValidateInvoiceResponse(BaseModel):
|
||||
valid: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class InvoicePaymentHashResponse(BaseModel):
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceAmountMsatResponse(BaseModel):
|
||||
amount_msat: int | None = None
|
||||
|
||||
|
||||
class InvoiceExpiryResponse(BaseModel):
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
class InvoiceMemoResponse(BaseModel):
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class VerifyPreimageRequest(BaseModel):
|
||||
preimage: str = Field(..., min_length=64, max_length=64)
|
||||
payment_hash: str = Field(..., min_length=64, max_length=64)
|
||||
|
||||
|
||||
class VerifyPreimageResponse(BaseModel):
|
||||
valid: bool
|
||||
|
||||
|
||||
class RandomSecretAndHashRequest(BaseModel):
|
||||
length: int = Field(32, ge=16, le=64)
|
||||
|
||||
|
||||
class RandomSecretAndHashResponse(BaseModel):
|
||||
secret: str
|
||||
hash: str
|
||||
|
||||
|
||||
class RandomIdRequest(BaseModel):
|
||||
prefix: str = Field(..., min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RandomIdResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class NowResponse(BaseModel):
|
||||
timestamp: int
|
||||
|
||||
|
||||
class LogRequest(BaseModel):
|
||||
level: Literal["debug", "info", "warning", "error"] = "info"
|
||||
message: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LogResponse(BaseModel):
|
||||
ok: bool = True
|
||||
412
lnbits/core/wasm_ext/api/permissions.py
Normal file
412
lnbits/core/wasm_ext/api/permissions.py
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
|
||||
from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids
|
||||
from lnbits.core.wasm_ext.api.websockets import (
|
||||
WEBSOCKET_PUBLISH_MAX_MESSAGES_PER_SECOND_LIMIT,
|
||||
)
|
||||
from lnbits.core.wasm_ext.client.http import _request_origin
|
||||
from lnbits.core.wasm_ext.wasm.config import (
|
||||
WasmExtensionConfig,
|
||||
parse_wasm_extension_config,
|
||||
)
|
||||
|
||||
_POLICY_AWARE_PERMISSION_IDS = {
|
||||
"ext.storage.append_public",
|
||||
"ext.storage.read_public",
|
||||
"extension.api.request",
|
||||
"http.request",
|
||||
"wallet.create_invoice_public",
|
||||
"websocket.publish",
|
||||
}
|
||||
_OWNER_ID_FIELD = "__lnbits_owner_id__"
|
||||
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
||||
PUBLIC_APPEND_MAX_ROWS_PER_SOURCE_LIMIT = 1_000_000
|
||||
|
||||
|
||||
def validate_extension_permissions(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[ExtensionPermission]:
|
||||
known_permission_ids = extension_api_permission_ids()
|
||||
normalized_permissions: list[ExtensionPermission] = []
|
||||
unknown_ids: list[str] = []
|
||||
|
||||
for permission in permissions:
|
||||
if permission.id not in known_permission_ids:
|
||||
unknown_ids.append(permission.id)
|
||||
if strict:
|
||||
continue
|
||||
normalized_permissions.append(permission.copy())
|
||||
|
||||
if unknown_ids and strict:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' requests unknown permissions: "
|
||||
+ ", ".join(sorted(set(unknown_ids)))
|
||||
)
|
||||
|
||||
return normalized_permissions
|
||||
|
||||
|
||||
def validate_wasm_extension_permissions(
|
||||
ext_info: InstallableExtension,
|
||||
granted_permissions: list[ExtensionPermission] | None,
|
||||
extension_config: dict[str, Any] | WasmExtensionConfig,
|
||||
*,
|
||||
allow_admin_policy_overrides: bool = False,
|
||||
) -> list[ExtensionPermission]:
|
||||
if isinstance(extension_config, WasmExtensionConfig):
|
||||
config = extension_config
|
||||
elif extension_config.get("extension_type") != "wasm":
|
||||
return []
|
||||
else:
|
||||
config = parse_wasm_extension_config(ext_info.id, extension_config)
|
||||
|
||||
requested_permissions = validate_extension_permissions(
|
||||
ext_info.id, config.permissions
|
||||
)
|
||||
_validate_requested_permission_policies(ext_info.id, requested_permissions)
|
||||
if not requested_permissions:
|
||||
return []
|
||||
|
||||
if granted_permissions is None:
|
||||
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
|
||||
|
||||
granted_permissions = validate_extension_permissions(
|
||||
ext_info.id,
|
||||
granted_permissions,
|
||||
)
|
||||
requested_by_id = _permission_index(ext_info.id, requested_permissions, "requested")
|
||||
granted_by_id = _permission_index(ext_info.id, granted_permissions, "granted")
|
||||
|
||||
extra_granted_ids = sorted(set(granted_by_id) - set(requested_by_id))
|
||||
if extra_granted_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted unrequested permissions: "
|
||||
+ ", ".join(extra_granted_ids)
|
||||
)
|
||||
|
||||
effective_permissions: list[ExtensionPermission] = []
|
||||
for permission_id, granted_permission in granted_by_id.items():
|
||||
requested_permission = requested_by_id[permission_id]
|
||||
if not _permission_grant_is_subset(
|
||||
requested_permission,
|
||||
granted_permission,
|
||||
allow_admin_policy_overrides=allow_admin_policy_overrides,
|
||||
):
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted broader policies for "
|
||||
f"permission '{permission_id}'."
|
||||
)
|
||||
effective_permissions.append(
|
||||
requested_permission.copy(update={"policies": granted_permission.policies})
|
||||
)
|
||||
|
||||
return effective_permissions
|
||||
|
||||
|
||||
def _permission_index(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
source: str,
|
||||
) -> dict[str, ExtensionPermission]:
|
||||
indexed: dict[str, ExtensionPermission] = {}
|
||||
duplicate_ids: list[str] = []
|
||||
|
||||
for permission in permissions:
|
||||
if permission.id in indexed:
|
||||
duplicate_ids.append(permission.id)
|
||||
continue
|
||||
indexed[permission.id] = permission
|
||||
|
||||
if duplicate_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' has duplicate {source} permissions: "
|
||||
+ ", ".join(sorted(set(duplicate_ids)))
|
||||
)
|
||||
return indexed
|
||||
|
||||
|
||||
def _permission_grant_is_subset(
|
||||
requested: ExtensionPermission,
|
||||
granted: ExtensionPermission,
|
||||
*,
|
||||
allow_admin_policy_overrides: bool = False,
|
||||
) -> bool:
|
||||
if requested.id != granted.id:
|
||||
return False
|
||||
if requested.id not in _POLICY_AWARE_PERMISSION_IDS:
|
||||
return True
|
||||
if requested.id == "http.request":
|
||||
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "extension.api.request":
|
||||
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "ext.storage.append_public":
|
||||
return _public_storage_append_grant_is_subset(
|
||||
requested.policies,
|
||||
granted.policies,
|
||||
allow_max_rows_per_source_override=allow_admin_policy_overrides,
|
||||
)
|
||||
if requested.id == "ext.storage.read_public":
|
||||
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "wallet.create_invoice_public":
|
||||
return _public_invoice_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "websocket.publish":
|
||||
return _websocket_publish_grant_is_subset(
|
||||
requested.policies,
|
||||
granted.policies,
|
||||
allow_max_messages_per_second_override=allow_admin_policy_overrides,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _validate_requested_permission_policies(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
) -> None:
|
||||
for permission in permissions:
|
||||
if permission.id != "websocket.publish":
|
||||
continue
|
||||
if _websocket_publish_policy(permission.policies) is None:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' requests invalid policies for permission "
|
||||
"'websocket.publish'."
|
||||
)
|
||||
|
||||
|
||||
def _policy_list(policies: list[Any] | None) -> list[Any]:
|
||||
return policies if isinstance(policies, list) else []
|
||||
|
||||
|
||||
def _http_request_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
return _http_origins(granted_policies).issubset(_http_origins(requested_policies))
|
||||
|
||||
|
||||
def _http_origins(policies: list[Any] | None) -> set[str]:
|
||||
origins: set[str] = set()
|
||||
for policy in _policy_list(policies):
|
||||
host = policy.get("host") if isinstance(policy, dict) else policy
|
||||
if not isinstance(host, str) or not host:
|
||||
continue
|
||||
try:
|
||||
origins.add(_request_origin(host))
|
||||
except PermissionError:
|
||||
continue
|
||||
return origins
|
||||
|
||||
|
||||
def _extension_api_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
requested_targets = _extension_api_targets(requested_policies)
|
||||
granted_targets = _extension_api_targets(granted_policies)
|
||||
for extension_id, granted_access in granted_targets.items():
|
||||
requested_access = requested_targets.get(extension_id)
|
||||
if requested_access is None or not granted_access.issubset(requested_access):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extension_api_targets(policies: list[Any] | None) -> dict[str, set[str]]:
|
||||
targets: dict[str, set[str]] = {}
|
||||
for policy in _policy_list(policies):
|
||||
extension_id: str | None = None
|
||||
access: list[Any] = []
|
||||
if isinstance(policy, str):
|
||||
extension_id = policy
|
||||
access = ["read"]
|
||||
elif isinstance(policy, dict):
|
||||
raw_extension_id = policy.get("id")
|
||||
raw_access = policy.get("access")
|
||||
if isinstance(raw_extension_id, str) and isinstance(raw_access, list):
|
||||
extension_id = raw_extension_id
|
||||
access = raw_access
|
||||
if not extension_id or extension_id in targets:
|
||||
continue
|
||||
clean_access = {
|
||||
item
|
||||
for item in access
|
||||
if isinstance(item, str) and item in {"read", "write"}
|
||||
}
|
||||
if clean_access:
|
||||
targets[extension_id] = clean_access
|
||||
return targets
|
||||
|
||||
|
||||
def _public_storage_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
requested_tables = _public_storage_tables(requested_policies)
|
||||
granted_tables = _public_storage_tables(granted_policies)
|
||||
for table_name, granted_policy in granted_tables.items():
|
||||
requested_policy = requested_tables.get(table_name)
|
||||
if requested_policy is None:
|
||||
return False
|
||||
if not granted_policy["public_fields"].issubset(
|
||||
requested_policy["public_fields"]
|
||||
):
|
||||
return False
|
||||
requested_source_id_field = requested_policy["source_id_field"]
|
||||
if (
|
||||
requested_source_id_field
|
||||
and granted_policy["source_id_field"] != requested_source_id_field
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
|
||||
tables: dict[str, dict[str, Any]] = {}
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table_name = policy.get("table_name")
|
||||
public_fields = policy.get("public_fields")
|
||||
source_id_field = policy.get("source_id_field")
|
||||
if (
|
||||
not isinstance(table_name, str)
|
||||
or table_name in tables
|
||||
or not isinstance(public_fields, list)
|
||||
or (
|
||||
source_id_field is not None
|
||||
and (not isinstance(source_id_field, str) or not source_id_field)
|
||||
)
|
||||
):
|
||||
continue
|
||||
fields = {field for field in public_fields if isinstance(field, str) and field}
|
||||
if fields:
|
||||
tables[table_name] = {
|
||||
"public_fields": fields,
|
||||
"source_id_field": source_id_field,
|
||||
}
|
||||
return tables
|
||||
|
||||
|
||||
def _public_storage_append_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
*,
|
||||
allow_max_rows_per_source_override: bool = False,
|
||||
) -> bool:
|
||||
requested_targets = _public_storage_append_targets(requested_policies)
|
||||
granted_targets = _public_storage_append_targets(granted_policies)
|
||||
if len(granted_targets) != len(_policy_list(granted_policies)):
|
||||
return False
|
||||
for target, granted_policy in granted_targets.items():
|
||||
requested_policy = requested_targets.get(target)
|
||||
if requested_policy is None:
|
||||
return False
|
||||
if not granted_policy["allowed_fields"].issubset(
|
||||
requested_policy["allowed_fields"]
|
||||
):
|
||||
return False
|
||||
if (
|
||||
granted_policy["max_rows_per_source"]
|
||||
> requested_policy["max_rows_per_source"]
|
||||
and not allow_max_rows_per_source_override
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
|
||||
targets: dict[Any, dict] = {}
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table = policy.get("table")
|
||||
source_table = policy.get("source_table")
|
||||
source_id_field = policy.get("source_id_field")
|
||||
allowed_fields = policy.get("allowed_fields")
|
||||
max_rows_per_source = policy.get(
|
||||
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
|
||||
)
|
||||
if (
|
||||
not isinstance(table, str)
|
||||
or not table
|
||||
or not isinstance(source_table, str)
|
||||
or not source_table
|
||||
or not isinstance(source_id_field, str)
|
||||
or not source_id_field
|
||||
or source_id_field == "id"
|
||||
or not isinstance(allowed_fields, list)
|
||||
or isinstance(max_rows_per_source, bool)
|
||||
or not isinstance(max_rows_per_source, int)
|
||||
or max_rows_per_source <= 0
|
||||
or max_rows_per_source > PUBLIC_APPEND_MAX_ROWS_PER_SOURCE_LIMIT
|
||||
):
|
||||
continue
|
||||
fields = {field for field in allowed_fields if isinstance(field, str) and field}
|
||||
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
|
||||
continue
|
||||
targets[(table, source_table, source_id_field)] = {
|
||||
"allowed_fields": fields,
|
||||
"max_rows_per_source": max_rows_per_source,
|
||||
}
|
||||
return targets
|
||||
|
||||
|
||||
def _public_invoice_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
return _public_invoice_sources(granted_policies).issubset(
|
||||
_public_invoice_sources(requested_policies)
|
||||
)
|
||||
|
||||
|
||||
def _public_invoice_sources(policies: list[Any] | None) -> set[tuple[str, str]]:
|
||||
sources: set[tuple[str, str]] = set()
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table = policy.get("table")
|
||||
wallet_field = policy.get("wallet_field")
|
||||
if isinstance(table, str) and table and isinstance(wallet_field, str):
|
||||
sources.add((table, wallet_field))
|
||||
return sources
|
||||
|
||||
|
||||
def _websocket_publish_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
*,
|
||||
allow_max_messages_per_second_override: bool = False,
|
||||
) -> bool:
|
||||
requested_policy = _websocket_publish_policy(requested_policies)
|
||||
granted_policy = _websocket_publish_policy(granted_policies)
|
||||
if requested_policy is None or granted_policy is None:
|
||||
return False
|
||||
if (
|
||||
granted_policy["max_messages_per_second"]
|
||||
> requested_policy["max_messages_per_second"]
|
||||
and not allow_max_messages_per_second_override
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _websocket_publish_policy(policies: list[Any] | None) -> dict[str, int] | None:
|
||||
policy_list = _policy_list(policies)
|
||||
if len(policy_list) != 1:
|
||||
return None
|
||||
policy = policy_list[0]
|
||||
if not isinstance(policy, dict):
|
||||
return None
|
||||
max_messages_per_second = policy.get("max_messages_per_second")
|
||||
if (
|
||||
isinstance(max_messages_per_second, bool)
|
||||
or not isinstance(max_messages_per_second, int)
|
||||
or max_messages_per_second <= 0
|
||||
or max_messages_per_second > WEBSOCKET_PUBLISH_MAX_MESSAGES_PER_SECOND_LIMIT
|
||||
):
|
||||
return None
|
||||
return {"max_messages_per_second": max_messages_per_second}
|
||||
205
lnbits/core/wasm_ext/api/registry.py
Normal file
205
lnbits/core/wasm_ext/api/registry.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar, cast, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
|
||||
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
||||
_EXTENSION_RUNTIME_PERMISSION_IDS = {
|
||||
"ui.camera.scan_qr",
|
||||
"wallet.pay_invoice",
|
||||
"wallet.pay_invoice_background",
|
||||
"wallet.payments.watch",
|
||||
"websocket.subscribe",
|
||||
}
|
||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||
|
||||
|
||||
def extension_api_method(
|
||||
*,
|
||||
method_id: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
host_name: str,
|
||||
sdk_name: str,
|
||||
description: str,
|
||||
host_interface: str = "host",
|
||||
required_permission: str | None = None,
|
||||
require_auth: bool = True,
|
||||
) -> Callable[
|
||||
[Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]],
|
||||
Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
]:
|
||||
export = ExtensionAPIMethodExport(
|
||||
method_id=method_id,
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
host_interface=host_interface,
|
||||
host_name=host_name,
|
||||
sdk_name=sdk_name,
|
||||
description=description,
|
||||
required_permission=required_permission,
|
||||
require_auth=require_auth,
|
||||
)
|
||||
|
||||
def decorator(
|
||||
function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]:
|
||||
@wraps(function)
|
||||
async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel:
|
||||
api = getattr(self, "api", self)
|
||||
if require_auth and not api.has_authenticated_context():
|
||||
raise PermissionError(
|
||||
f"Extension API method '{method_id}' requires authentication."
|
||||
)
|
||||
api.require_permission(required_permission)
|
||||
return await function(self, request)
|
||||
|
||||
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def list_extension_api_methods(
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> list[ExtensionAPIMethod]:
|
||||
api_cls = _default_api_cls(api_cls)
|
||||
methods: list[ExtensionAPIMethod] = []
|
||||
|
||||
for prefix, method_cls in _extension_api_method_sources(api_cls):
|
||||
for python_name, function in inspect.getmembers(method_cls, inspect.isfunction):
|
||||
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
|
||||
if not export:
|
||||
continue
|
||||
|
||||
request_model, response_model = _get_method_models(function)
|
||||
methods.append(
|
||||
ExtensionAPIMethod(
|
||||
method_id=export.method_id,
|
||||
namespace=export.namespace,
|
||||
name=export.name,
|
||||
python_name=f"{prefix}.{python_name}" if prefix else python_name,
|
||||
host_interface=export.host_interface,
|
||||
host_name=export.host_name,
|
||||
sdk_name=export.sdk_name,
|
||||
description=export.description,
|
||||
request_model=request_model,
|
||||
response_model=response_model,
|
||||
required_permission=export.required_permission,
|
||||
require_auth=export.require_auth,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(methods, key=lambda method: method.method_id)
|
||||
|
||||
|
||||
def extension_api_permission_ids(api_cls: type[Any] | None = None) -> set[str]:
|
||||
permissions = {
|
||||
method.required_permission
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
if method.required_permission
|
||||
}
|
||||
permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS)
|
||||
return permissions
|
||||
|
||||
|
||||
def get_extension_api_method(
|
||||
method_id: str,
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> ExtensionAPIMethod:
|
||||
for method in list_extension_api_methods(api_cls):
|
||||
if method.method_id == method_id:
|
||||
return method
|
||||
raise KeyError(f"Unknown extension API method '{method_id}'.")
|
||||
|
||||
|
||||
def extension_api_contract(api_cls: type[Any] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"version": 1,
|
||||
"methods": [
|
||||
{
|
||||
"id": method.method_id,
|
||||
"namespace": method.namespace,
|
||||
"name": method.name,
|
||||
"python_name": method.python_name,
|
||||
"host_interface": method.host_interface,
|
||||
"host_name": method.host_name,
|
||||
"sdk_name": method.sdk_name,
|
||||
"sdk_qualified_name": method.sdk_qualified_name,
|
||||
"description": method.description,
|
||||
"required_permission": method.required_permission,
|
||||
"require_auth": method.require_auth,
|
||||
"request_schema": method.request_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
"response_schema": method.response_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
}
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _default_api_cls(api_cls: type[Any] | None) -> type[Any]:
|
||||
if api_cls is not None:
|
||||
return api_cls
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
return ExtensionHostAPI
|
||||
|
||||
|
||||
def _extension_api_method_sources(
|
||||
api_cls: type[Any],
|
||||
) -> list[tuple[str, type[Any]]]:
|
||||
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
if issubclass(api_cls, ExtensionHostAPI):
|
||||
from .utils import extension_api_utils_method_classes
|
||||
|
||||
sources.extend(extension_api_utils_method_classes().items())
|
||||
return sources
|
||||
|
||||
|
||||
def _get_method_models(
|
||||
function: Callable[..., object],
|
||||
) -> tuple[type[BaseModel], type[BaseModel]]:
|
||||
signature = inspect.signature(function)
|
||||
request_parameters = [
|
||||
parameter
|
||||
for parameter in signature.parameters.values()
|
||||
if parameter.name != "self"
|
||||
]
|
||||
if len(request_parameters) != 1:
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' must accept one request model."
|
||||
)
|
||||
|
||||
hints = get_type_hints(function)
|
||||
request_model = hints.get(request_parameters[0].name)
|
||||
response_model = hints.get("return")
|
||||
|
||||
if not _is_pydantic_model(request_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' request must be a BaseModel."
|
||||
)
|
||||
if not _is_pydantic_model(response_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' response must be a BaseModel."
|
||||
)
|
||||
|
||||
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
|
||||
|
||||
|
||||
def _is_pydantic_model(value: object) -> bool:
|
||||
return isinstance(value, type) and issubclass(value, BaseModel)
|
||||
141
lnbits/core/wasm_ext/api/runtime.py
Normal file
141
lnbits/core/wasm_ext/api/runtime.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod
|
||||
from .registry import list_extension_api_methods
|
||||
|
||||
HostImport = Callable[..., Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class ExtensionAPIHost:
|
||||
def __init__(
|
||||
self,
|
||||
api: ExtensionHostAPI,
|
||||
*,
|
||||
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
|
||||
) -> None:
|
||||
self.api = api
|
||||
self.methods = list_extension_api_methods(api_cls)
|
||||
self._methods_by_host_name = self._index_methods(self.methods)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
host_name: str,
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
method = self._require_method(host_name)
|
||||
from lnbits.core.services.extensions import record_wasm_invocation_host_call
|
||||
|
||||
record_wasm_invocation_host_call(self.api.invocation_id, method.method_id)
|
||||
request = self._request_model(method, payload)
|
||||
handler = _resolve_attr_path(self.api, method.python_name)
|
||||
response = handler(request)
|
||||
if inspect.isawaitable(response):
|
||||
response = await response
|
||||
return self._response_payload(method, response)
|
||||
|
||||
def imports(self) -> dict[str, HostImport]:
|
||||
return self.imports_for_interface("host")
|
||||
|
||||
def import_object(self) -> dict[str, dict[str, HostImport]]:
|
||||
interfaces = sorted({method.host_interface for method in self.methods})
|
||||
return {
|
||||
f"lnbits:extension/{interface}": self.imports_for_interface(interface)
|
||||
for interface in interfaces
|
||||
}
|
||||
|
||||
def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]:
|
||||
return {
|
||||
_snake_to_camel(method.host_name): self._make_import(method)
|
||||
for method in self.methods
|
||||
if method.host_interface == host_interface
|
||||
}
|
||||
|
||||
def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
|
||||
async def host_import(
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.invoke(method.method_id, payload)
|
||||
|
||||
return host_import
|
||||
|
||||
def _require_method(self, host_name: str) -> ExtensionAPIMethod:
|
||||
method = self._methods_by_host_name.get(host_name)
|
||||
if not method:
|
||||
raise KeyError(f"Unknown extension host function '{host_name}'.")
|
||||
return method
|
||||
|
||||
@staticmethod
|
||||
def _index_methods(
|
||||
methods: list[ExtensionAPIMethod],
|
||||
) -> dict[str, ExtensionAPIMethod]:
|
||||
index: dict[str, ExtensionAPIMethod] = {}
|
||||
for method in methods:
|
||||
for host_name in {
|
||||
method.method_id,
|
||||
f"{method.host_interface}:{method.host_name}",
|
||||
method.host_name,
|
||||
_snake_to_camel(method.host_name),
|
||||
method.host_name.replace("_", "-"),
|
||||
}:
|
||||
index[host_name] = method
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _request_model(
|
||||
method: ExtensionAPIMethod,
|
||||
payload: Mapping[str, Any] | BaseModel | None,
|
||||
) -> BaseModel:
|
||||
if isinstance(payload, method.request_model):
|
||||
return payload
|
||||
if isinstance(payload, BaseModel):
|
||||
payload = payload.dict()
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError(
|
||||
f"Host function '{method.host_name}' expects an object payload."
|
||||
)
|
||||
data = {_to_snake(key): value for key, value in payload.items()}
|
||||
if isinstance(data.get("extra"), list):
|
||||
data["extra"] = dict(data["extra"])
|
||||
if isinstance(data.get("headers"), list):
|
||||
data["headers"] = dict(data["headers"])
|
||||
return method.request_model.parse_obj(data)
|
||||
|
||||
@staticmethod
|
||||
def _response_payload(
|
||||
method: ExtensionAPIMethod,
|
||||
response: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(response, method.response_model):
|
||||
response = method.response_model.parse_obj(response)
|
||||
payload = response.dict()
|
||||
if method.method_id in {"http.request", "extension.api.request"} and isinstance(
|
||||
payload.get("headers"), Mapping
|
||||
):
|
||||
payload["headers"] = list(payload["headers"].items())
|
||||
return {_snake_to_camel(key): value for key, value in payload.items()}
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _to_snake(value: str) -> str:
|
||||
value = value.replace("-", "_")
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
|
||||
|
||||
|
||||
def _resolve_attr_path(value: Any, path: str) -> Any:
|
||||
for part in path.split("."):
|
||||
value = getattr(value, part)
|
||||
return value
|
||||
475
lnbits/core/wasm_ext/api/utils.py
Normal file
475
lnbits/core/wasm_ext/api/utils.py
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
|
||||
from lnurl import handle as lnurl_handle
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
get_fiat_rate_and_price_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from .lnurl import (
|
||||
lnurl_pay_response_int,
|
||||
lnurl_pay_response_metadata_json,
|
||||
lnurl_pay_response_text,
|
||||
normalize_lnurl,
|
||||
)
|
||||
from .models import (
|
||||
Bolt11Request,
|
||||
CurrencyConvertRequest,
|
||||
CurrencyConvertResponse,
|
||||
CurrencyListResponse,
|
||||
CurrencyRateRequest,
|
||||
CurrencyRateResponse,
|
||||
DecodeInvoiceResponse,
|
||||
EmptyRequest,
|
||||
FiatToSatsRequest,
|
||||
FiatToSatsResponse,
|
||||
InvoiceAmountMsatResponse,
|
||||
InvoiceExpiryResponse,
|
||||
InvoiceMemoResponse,
|
||||
InvoicePaymentHashResponse,
|
||||
LnurlResolveRequest,
|
||||
LnurlResolveResponse,
|
||||
RandomSecretAndHashRequest,
|
||||
RandomSecretAndHashResponse,
|
||||
SatsToFiatRequest,
|
||||
SatsToFiatResponse,
|
||||
ServerHealthResponse,
|
||||
ValidateInvoiceResponse,
|
||||
VerifyPreimageRequest,
|
||||
VerifyPreimageResponse,
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
|
||||
class ExtensionAPIUtils:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
permission_set = set(permissions)
|
||||
self.currencies = ExtensionCurrencyUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.server = ExtensionServerUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.lightning = ExtensionLightningUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.lnurl = ExtensionLnurlUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
|
||||
|
||||
class _ExtensionAPIUtilsGroup:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.permissions = set(permissions)
|
||||
self.authenticated = authenticated
|
||||
|
||||
def require_permission(self, permission: str | None) -> None:
|
||||
if permission and permission not in self.permissions:
|
||||
raise PermissionError(
|
||||
f"Extension '{self.extension_id}' is missing permission '{permission}'."
|
||||
)
|
||||
|
||||
def has_authenticated_context(self) -> bool:
|
||||
return self.authenticated
|
||||
|
||||
|
||||
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.list",
|
||||
namespace="utils.currencies",
|
||||
name="List currencies",
|
||||
host_interface="utils-currencies",
|
||||
host_name="list_currencies",
|
||||
sdk_name="list",
|
||||
description="List currencies supported by LNbits exchange-rate conversion.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
|
||||
|
||||
return CurrencyListResponse(currencies=allowed_currencies())
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.rate",
|
||||
namespace="utils.currencies",
|
||||
name="Get currency rate",
|
||||
host_interface="utils-currencies",
|
||||
host_name="rate",
|
||||
sdk_name="rate",
|
||||
description="Get sats-per-fiat and BTC price for a currency.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
|
||||
|
||||
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
|
||||
return CurrencyRateResponse(rate=rate, price=price)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.convert",
|
||||
namespace="utils.currencies",
|
||||
name="Convert currency amount",
|
||||
host_interface="utils-currencies",
|
||||
host_name="convert",
|
||||
sdk_name="convert",
|
||||
description="Convert between sats, BTC, and supported fiat currencies.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
|
||||
|
||||
from_currency = request.from_currency
|
||||
if from_currency == "sats":
|
||||
from_currency = "sat"
|
||||
|
||||
amounts: list[tuple[str, float]] = []
|
||||
if from_currency == "sat":
|
||||
sats = int(request.amount)
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
amounts.append(("sats", sats))
|
||||
for currency in request.to.split(","):
|
||||
currency = currency.strip()
|
||||
if currency:
|
||||
amounts.append(
|
||||
(
|
||||
currency.upper(),
|
||||
await satoshis_amount_as_fiat(sats, currency),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
|
||||
amounts.append((from_currency.upper(), request.amount))
|
||||
amounts.append(("sats", sats))
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
return CurrencyConvertResponse(amounts=amounts)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.fiat_to_sats",
|
||||
namespace="utils.currencies",
|
||||
name="Convert fiat to sats",
|
||||
host_interface="utils-currencies",
|
||||
host_name="fiat_to_sats",
|
||||
sdk_name="fiatToSats",
|
||||
description="Convert a fiat amount to sats.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
|
||||
|
||||
return FiatToSatsResponse(
|
||||
amount_sat=await fiat_amount_as_satoshis(
|
||||
request.amount,
|
||||
request.currency,
|
||||
)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.sats_to_fiat",
|
||||
namespace="utils.currencies",
|
||||
name="Convert sats to fiat",
|
||||
host_interface="utils-currencies",
|
||||
host_name="sats_to_fiat",
|
||||
sdk_name="satsToFiat",
|
||||
description="Convert a sats amount to fiat.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
|
||||
|
||||
return SatsToFiatResponse(
|
||||
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
|
||||
)
|
||||
|
||||
|
||||
class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.server.health",
|
||||
namespace="utils.server",
|
||||
name="Server health",
|
||||
host_interface="utils-server",
|
||||
host_name="health",
|
||||
sdk_name="health",
|
||||
description="Return basic public LNbits server health data.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
|
||||
|
||||
return ServerHealthResponse(
|
||||
server_time=int(time.time()),
|
||||
up_time=settings.lnbits_server_up_time,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionLnurlUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.lnurl.resolve",
|
||||
namespace="utils.lnurl",
|
||||
name="Resolve LNURL-pay",
|
||||
host_interface="utils-lnurl",
|
||||
host_name="resolve",
|
||||
sdk_name="resolve",
|
||||
description="Resolve a Lightning Address or LNURL-pay request.",
|
||||
required_permission="wallet.pay_invoice",
|
||||
require_auth=True,
|
||||
)
|
||||
async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse:
|
||||
normalized_lnurl = normalize_lnurl(request.lnurl)
|
||||
response = await lnurl_handle(
|
||||
normalized_lnurl,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
if isinstance(response, LnurlErrorResponse):
|
||||
raise LnurlResponseException(response.reason)
|
||||
if not isinstance(response, LnurlPayResponse):
|
||||
raise LnurlResponseException(
|
||||
"Invalid LNURL response. Expected LnurlPayResponse."
|
||||
)
|
||||
|
||||
min_sendable_msat = lnurl_pay_response_int(
|
||||
response, "min_sendable", "minSendable"
|
||||
)
|
||||
max_sendable_msat = lnurl_pay_response_int(
|
||||
response, "max_sendable", "maxSendable"
|
||||
)
|
||||
image = getattr(response, "image", None)
|
||||
return LnurlResolveResponse(
|
||||
lnurl=normalized_lnurl,
|
||||
domain=getattr(response, "domain", None),
|
||||
description=lnurl_pay_response_text(response),
|
||||
min_sendable_msat=min_sendable_msat,
|
||||
max_sendable_msat=max_sendable_msat,
|
||||
comment_allowed=lnurl_pay_response_int(
|
||||
response, "comment_allowed", "commentAllowed"
|
||||
),
|
||||
fixed=bool(
|
||||
getattr(
|
||||
response,
|
||||
"fixed",
|
||||
min_sendable_msat == max_sendable_msat,
|
||||
)
|
||||
),
|
||||
image=str(image) if image is not None else None,
|
||||
metadata_json=lnurl_pay_response_metadata_json(response),
|
||||
)
|
||||
|
||||
|
||||
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.decode_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Decode Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="decode_invoice",
|
||||
sdk_name="decodeInvoice",
|
||||
description="Decode a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse:
|
||||
invoice = _decode_bolt11(request.bolt11)
|
||||
return _decoded_invoice_response(invoice)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.validate_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Validate Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="validate_invoice",
|
||||
sdk_name="validateInvoice",
|
||||
description="Validate whether a string is a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse:
|
||||
try:
|
||||
_decode_bolt11(request.bolt11)
|
||||
return ValidateInvoiceResponse(valid=True)
|
||||
except Exception as exc:
|
||||
return ValidateInvoiceResponse(valid=False, error=str(exc))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_payment_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice payment hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_payment_hash",
|
||||
sdk_name="invoicePaymentHash",
|
||||
description="Get the payment hash from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_payment_hash(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoicePaymentHashResponse:
|
||||
return InvoicePaymentHashResponse(
|
||||
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_amount_msat",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice amount",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_amount_msat",
|
||||
sdk_name="invoiceAmountMsat",
|
||||
description="Get the amount in msat from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_amount_msat(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoiceAmountMsatResponse:
|
||||
return InvoiceAmountMsatResponse(
|
||||
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_expiry",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice expiry",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_expiry",
|
||||
sdk_name="invoiceExpiry",
|
||||
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse:
|
||||
return InvoiceExpiryResponse(
|
||||
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_memo",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice memo",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_memo",
|
||||
sdk_name="invoiceMemo",
|
||||
description="Get the memo from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse:
|
||||
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.verify_preimage",
|
||||
namespace="utils.lightning",
|
||||
name="Verify Lightning preimage",
|
||||
host_interface="utils-lightning",
|
||||
host_name="verify_preimage",
|
||||
sdk_name="verifyPreimage",
|
||||
description="Verify that a preimage matches a payment hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def verify_preimage(
|
||||
self, request: VerifyPreimageRequest
|
||||
) -> VerifyPreimageResponse:
|
||||
|
||||
return VerifyPreimageResponse(
|
||||
valid=verify_preimage(request.preimage, request.payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.random_secret_and_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Random Lightning secret and hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="random_secret_and_hash",
|
||||
sdk_name="randomSecretAndHash",
|
||||
description="Create a random secret and matching SHA256 hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def random_secret_and_hash(
|
||||
self, request: RandomSecretAndHashRequest
|
||||
) -> RandomSecretAndHashResponse:
|
||||
|
||||
secret, payment_hash = random_secret_and_hash(request.length)
|
||||
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
|
||||
|
||||
|
||||
def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]:
|
||||
return {
|
||||
"utils.currencies": ExtensionCurrencyUtils,
|
||||
"utils.server": ExtensionServerUtils,
|
||||
"utils.lnurl": ExtensionLnurlUtils,
|
||||
"utils.lightning": ExtensionLightningUtils,
|
||||
}
|
||||
|
||||
|
||||
def _decode_bolt11(payment_request: str) -> Any:
|
||||
|
||||
return bolt11.decode(payment_request)
|
||||
|
||||
|
||||
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
|
||||
return DecodeInvoiceResponse(
|
||||
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
|
||||
amount_msat=_invoice_amount_msat(invoice),
|
||||
expiry=_invoice_expiry(invoice),
|
||||
expires_at=_invoice_expires_at(invoice),
|
||||
memo=_invoice_memo(invoice),
|
||||
)
|
||||
|
||||
|
||||
def _invoice_amount_msat(invoice: Any) -> int | None:
|
||||
amount_msat = getattr(invoice, "amount_msat", None)
|
||||
if amount_msat is None:
|
||||
return None
|
||||
return int(amount_msat)
|
||||
|
||||
|
||||
def _invoice_expiry(invoice: Any) -> int | None:
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if expiry is None:
|
||||
return None
|
||||
return int(expiry)
|
||||
|
||||
|
||||
def _invoice_expires_at(invoice: Any) -> int | None:
|
||||
expiry_date = getattr(invoice, "expiry_date", None)
|
||||
if isinstance(expiry_date, datetime):
|
||||
return int(expiry_date.timestamp())
|
||||
|
||||
date = getattr(invoice, "date", None)
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if isinstance(date, datetime) and expiry is not None:
|
||||
return int(date.timestamp() + int(expiry))
|
||||
if isinstance(date, (int, float)) and expiry is not None:
|
||||
return int(date + int(expiry))
|
||||
return None
|
||||
|
||||
|
||||
def _invoice_memo(invoice: Any) -> str | None:
|
||||
memo = getattr(invoice, "description", None)
|
||||
return str(memo) if memo is not None else None
|
||||
176
lnbits/core/wasm_ext/api/websockets.py
Normal file
176
lnbits/core/wasm_ext/api/websockets.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
|
||||
WEBSOCKET_PUBLISH_MAX_MESSAGES_PER_SECOND_LIMIT = 100
|
||||
WEBSOCKET_CLIENT_MAX_MESSAGES_PER_SECOND = 60
|
||||
WEBSOCKET_CLIENT_MAX_MESSAGE_BYTES = 8192
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasmExtensionWebsocketConnection:
|
||||
extension_id: str
|
||||
item_id: str
|
||||
websocket: WebSocket
|
||||
|
||||
|
||||
class WasmExtensionWebsocketRateLimitError(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
|
||||
if not _EXTENSION_ID_RE.fullmatch(extension_id):
|
||||
raise ValueError("Extension websocket namespace is invalid.")
|
||||
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
|
||||
raise ValueError(
|
||||
"Extension websocket item ID must be 1-128 characters and contain "
|
||||
"only letters, numbers, colon, underscore, or dash."
|
||||
)
|
||||
return f"ext:{extension_id}:{item_id}"
|
||||
|
||||
|
||||
class WasmExtensionWebsocketHub:
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: list[WasmExtensionWebsocketConnection] = []
|
||||
self.publish_timestamps: dict[tuple[str, str], deque[float]] = {}
|
||||
self.client_timestamps: dict[int, deque[float]] = {}
|
||||
|
||||
async def connect(
|
||||
self, extension_id: str, item_id: str, websocket: WebSocket
|
||||
) -> WasmExtensionWebsocketConnection:
|
||||
scoped_websocket_item_id(extension_id, item_id)
|
||||
logger.debug(f"WASM websocket connected to {extension_id}:{item_id}")
|
||||
await websocket.accept()
|
||||
conn = WasmExtensionWebsocketConnection(
|
||||
extension_id=extension_id,
|
||||
item_id=item_id,
|
||||
websocket=websocket,
|
||||
)
|
||||
self.active_connections.append(conn)
|
||||
return conn
|
||||
|
||||
async def listen(self, conn: WasmExtensionWebsocketConnection) -> None:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
data = await conn.websocket.receive_text()
|
||||
if len(data.encode()) > WEBSOCKET_CLIENT_MAX_MESSAGE_BYTES:
|
||||
await conn.websocket.close(code=1009)
|
||||
self.disconnect(conn)
|
||||
break
|
||||
self._check_client_rate(conn)
|
||||
await self._broadcast_client_message(conn, data)
|
||||
except WebSocketDisconnect:
|
||||
self.disconnect(conn)
|
||||
break
|
||||
except WasmExtensionWebsocketRateLimitError:
|
||||
await conn.websocket.close(code=1008)
|
||||
self.disconnect(conn)
|
||||
break
|
||||
|
||||
def disconnect(self, conn: WasmExtensionWebsocketConnection) -> None:
|
||||
self.active_connections = [
|
||||
active_conn
|
||||
for active_conn in self.active_connections
|
||||
if active_conn.websocket != conn.websocket
|
||||
]
|
||||
self.client_timestamps.pop(id(conn.websocket), None)
|
||||
logger.debug(
|
||||
f"WASM websocket disconnected from {conn.extension_id}:{conn.item_id}"
|
||||
)
|
||||
|
||||
def get_connections(
|
||||
self, extension_id: str, item_id: str
|
||||
) -> list[WasmExtensionWebsocketConnection]:
|
||||
return [
|
||||
conn
|
||||
for conn in self.active_connections
|
||||
if conn.extension_id == extension_id and conn.item_id == item_id
|
||||
]
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
extension_id: str,
|
||||
item_id: str,
|
||||
data: str,
|
||||
*,
|
||||
max_messages_per_second: int,
|
||||
) -> None:
|
||||
scoped_websocket_item_id(extension_id, item_id)
|
||||
self._check_publish_rate(
|
||||
extension_id,
|
||||
item_id,
|
||||
max_messages_per_second=max_messages_per_second,
|
||||
)
|
||||
for conn in self.get_connections(extension_id, item_id):
|
||||
await self._send_to_connection(conn, data)
|
||||
|
||||
def _check_publish_rate(
|
||||
self,
|
||||
extension_id: str,
|
||||
item_id: str,
|
||||
*,
|
||||
max_messages_per_second: int,
|
||||
) -> None:
|
||||
if (
|
||||
isinstance(max_messages_per_second, bool)
|
||||
or not isinstance(max_messages_per_second, int)
|
||||
or max_messages_per_second <= 0
|
||||
or max_messages_per_second > WEBSOCKET_PUBLISH_MAX_MESSAGES_PER_SECOND_LIMIT
|
||||
):
|
||||
raise ValueError("Invalid websocket publish rate limit.")
|
||||
|
||||
now = time.monotonic()
|
||||
channel = (extension_id, item_id)
|
||||
timestamps = self.publish_timestamps.setdefault(channel, deque())
|
||||
while timestamps and now - timestamps[0] >= 1:
|
||||
timestamps.popleft()
|
||||
if len(timestamps) >= max_messages_per_second:
|
||||
raise WasmExtensionWebsocketRateLimitError(
|
||||
"WASM websocket publish rate limit exceeded."
|
||||
)
|
||||
timestamps.append(now)
|
||||
|
||||
def _check_client_rate(self, conn: WasmExtensionWebsocketConnection) -> None:
|
||||
now = time.monotonic()
|
||||
key = id(conn.websocket)
|
||||
timestamps = self.client_timestamps.setdefault(key, deque())
|
||||
while timestamps and now - timestamps[0] >= 1:
|
||||
timestamps.popleft()
|
||||
if len(timestamps) >= WEBSOCKET_CLIENT_MAX_MESSAGES_PER_SECOND:
|
||||
raise WasmExtensionWebsocketRateLimitError(
|
||||
"WASM websocket client rate limit exceeded."
|
||||
)
|
||||
timestamps.append(now)
|
||||
|
||||
async def _broadcast_client_message(
|
||||
self,
|
||||
conn: WasmExtensionWebsocketConnection,
|
||||
data: str,
|
||||
) -> None:
|
||||
for active_conn in self.get_connections(conn.extension_id, conn.item_id):
|
||||
if active_conn.websocket == conn.websocket:
|
||||
continue
|
||||
await self._send_to_connection(active_conn, data)
|
||||
|
||||
async def _send_to_connection(
|
||||
self,
|
||||
conn: WasmExtensionWebsocketConnection,
|
||||
data: str,
|
||||
) -> None:
|
||||
try:
|
||||
await conn.websocket.send_text(data)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
self.disconnect(conn)
|
||||
|
||||
|
||||
wasm_extension_websocket_hub = WasmExtensionWebsocketHub()
|
||||
4
lnbits/core/wasm_ext/client/__init__.py
Normal file
4
lnbits/core/wasm_ext/client/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .extensions import send_extension_api_request
|
||||
from .http import send_extension_http_request
|
||||
|
||||
__all__ = ["send_extension_api_request", "send_extension_http_request"]
|
||||
219
lnbits/core/wasm_ext/client/extensions.py
Normal file
219
lnbits/core/wasm_ext/client/extensions.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_installed_extension,
|
||||
get_user_active_extensions_ids,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..api.models import ExtensionApiRequest, HttpResponse
|
||||
|
||||
EXTENSION_API_TIMEOUT_SECONDS = 10.0
|
||||
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_READ_METHODS = {"GET", "HEAD"}
|
||||
_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
|
||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
async def send_extension_api_request(
|
||||
caller_extension_id: str,
|
||||
policies: list[Any],
|
||||
user_id: str | None,
|
||||
access_token: str | None,
|
||||
request: ExtensionApiRequest,
|
||||
*,
|
||||
timeout_ms: int | None = None,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> HttpResponse:
|
||||
if not user_id:
|
||||
raise PermissionError("Extension API requests require authentication.")
|
||||
if not access_token:
|
||||
raise PermissionError("Extension API requests require an account access token.")
|
||||
|
||||
target_extension_id = _target_extension_id(request.extension_id)
|
||||
access = _target_extension_access(policies, target_extension_id)
|
||||
_require_method_access(caller_extension_id, target_extension_id, access, request)
|
||||
await _require_enabled_extension(target_extension_id, user_id)
|
||||
|
||||
path = _extension_api_path(request.path)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("Extension API request body is too large.")
|
||||
|
||||
url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("Extension API request failed.") from exc
|
||||
|
||||
|
||||
def _target_extension_id(extension_id: str) -> str:
|
||||
target = extension_id.strip()
|
||||
if not target or not _EXTENSION_ID_RE.match(target):
|
||||
raise PermissionError("Extension API request has an invalid target extension.")
|
||||
return target
|
||||
|
||||
|
||||
def _target_extension_access(policies: list[Any], target_extension_id: str) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError(
|
||||
"Extension API requests require a non-empty extensions policy."
|
||||
)
|
||||
|
||||
for extension in policies:
|
||||
if isinstance(extension, str):
|
||||
extension_id = extension
|
||||
access = ["read"]
|
||||
elif isinstance(extension, dict):
|
||||
raw_extension_id = extension.get("id")
|
||||
raw_access = extension.get("access")
|
||||
if not isinstance(raw_extension_id, str):
|
||||
continue
|
||||
if not isinstance(raw_access, list):
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' "
|
||||
"has no access policy."
|
||||
)
|
||||
extension_id = raw_extension_id
|
||||
access = raw_access
|
||||
else:
|
||||
continue
|
||||
|
||||
if extension_id != target_extension_id:
|
||||
continue
|
||||
clean_access = {
|
||||
item
|
||||
for item in access
|
||||
if isinstance(item, str) and item in {"read", "write"}
|
||||
}
|
||||
if clean_access:
|
||||
return clean_access
|
||||
break
|
||||
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' is not allowed."
|
||||
)
|
||||
|
||||
|
||||
def _require_method_access(
|
||||
caller_extension_id: str,
|
||||
target_extension_id: str,
|
||||
access: set[str],
|
||||
request: ExtensionApiRequest,
|
||||
) -> None:
|
||||
if request.method in _READ_METHODS:
|
||||
required_access = "read"
|
||||
elif request.method in _WRITE_METHODS:
|
||||
required_access = "write"
|
||||
else:
|
||||
raise PermissionError("Extension API request method is not allowed.")
|
||||
|
||||
if required_access not in access:
|
||||
raise PermissionError(
|
||||
f"Extension '{caller_extension_id}' cannot {required_access} "
|
||||
f"extension '{target_extension_id}'."
|
||||
)
|
||||
|
||||
|
||||
async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None:
|
||||
extension = await get_installed_extension(target_extension_id)
|
||||
if not extension or not extension.active:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not installed or enabled."
|
||||
)
|
||||
|
||||
active_extensions = await get_user_active_extensions_ids(user_id)
|
||||
if target_extension_id not in active_extensions:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not active for this user."
|
||||
)
|
||||
|
||||
|
||||
def _extension_api_path(path: str) -> str:
|
||||
parts = urlsplit(path)
|
||||
if parts.scheme or parts.netloc:
|
||||
raise PermissionError("Extension API request path must be relative.")
|
||||
if parts.fragment:
|
||||
raise PermissionError("Extension API request path cannot include a fragment.")
|
||||
if not parts.path.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path must start with '/api/'.")
|
||||
|
||||
decoded_path = unquote(parts.path)
|
||||
path_parts = decoded_path.split("/")
|
||||
if any(part == ".." for part in path_parts):
|
||||
raise PermissionError("Extension API request path cannot traverse directories.")
|
||||
|
||||
normalized = posixpath.normpath(decoded_path)
|
||||
if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path is invalid.")
|
||||
|
||||
return urlunsplit(("", "", parts.path, parts.query, ""))
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> bytes:
|
||||
limit = (
|
||||
EXTENSION_API_MAX_RESPONSE_BYTES
|
||||
if max_response_bytes is None
|
||||
else max_response_bytes
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
raise ValueError("Extension API response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
|
||||
if timeout_ms is None:
|
||||
return default
|
||||
if timeout_ms <= 0:
|
||||
return None
|
||||
return timeout_ms / 1000
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
204
lnbits/core/wasm_ext/client/http.py
Normal file
204
lnbits/core/wasm_ext/client/http.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..api.models import HttpRequest, HttpResponse
|
||||
|
||||
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
|
||||
HTTP_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_FORBIDDEN_REQUEST_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"host",
|
||||
"proxy-authorization",
|
||||
"transfer-encoding",
|
||||
}
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
async def send_extension_http_request(
|
||||
extension_id: str,
|
||||
policies: list[Any],
|
||||
request: HttpRequest,
|
||||
*,
|
||||
timeout_ms: int | None = None,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> HttpResponse:
|
||||
allowed_origins = _allowed_origins(policies)
|
||||
origin = _request_origin(request.url)
|
||||
if origin not in allowed_origins:
|
||||
raise PermissionError(
|
||||
f"Extension '{extension_id}' is not allowed to request '{origin}'."
|
||||
)
|
||||
|
||||
await _reject_internal_host(request.url)
|
||||
headers = _request_headers(request.headers)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("HTTP request body is too large.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
request.url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("HTTP request failed.") from exc
|
||||
|
||||
|
||||
def _allowed_origins(policies: list[Any]) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError("HTTP requests require a non-empty hosts policy.")
|
||||
|
||||
origins: set[str] = set()
|
||||
for policy in policies:
|
||||
host = policy.get("host") if isinstance(policy, dict) else policy
|
||||
if not isinstance(host, str) or not host:
|
||||
continue
|
||||
origins.add(_request_origin(host))
|
||||
if not origins:
|
||||
raise PermissionError("HTTP requests require at least one valid host.")
|
||||
return origins
|
||||
|
||||
|
||||
def _request_origin(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise PermissionError("HTTP requests require https URLs.")
|
||||
if parsed.username or parsed.password:
|
||||
raise PermissionError("HTTP requests cannot include credentials in URLs.")
|
||||
if not parsed.hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
|
||||
hostname = parsed.hostname.lower()
|
||||
port = _url_port(parsed)
|
||||
if port is None or port == 443:
|
||||
return f"https://{hostname}"
|
||||
return f"https://{hostname}:{port}"
|
||||
|
||||
|
||||
def _url_port(parsed: Any) -> int | None:
|
||||
try:
|
||||
return parsed.port
|
||||
except ValueError as exc:
|
||||
raise PermissionError("HTTP request URL has an invalid port.") from exc
|
||||
|
||||
|
||||
async def _reject_internal_host(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
if hostname == "localhost" or hostname.endswith(".localhost"):
|
||||
raise PermissionError("HTTP requests cannot target localhost.")
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
_reject_internal_address(address)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for address in await _resolve_host(hostname):
|
||||
_reject_internal_address(address)
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
hostname: str,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
import asyncio
|
||||
|
||||
def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise PermissionError("HTTP request host could not be resolved.") from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
addresses.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return addresses
|
||||
|
||||
return await asyncio.to_thread(resolve)
|
||||
|
||||
|
||||
def _reject_internal_address(
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> None:
|
||||
if not address.is_global:
|
||||
raise PermissionError("HTTP requests cannot target internal network addresses.")
|
||||
|
||||
|
||||
def _request_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
header = key.strip()
|
||||
if not header:
|
||||
continue
|
||||
if header.lower() in _FORBIDDEN_REQUEST_HEADERS:
|
||||
continue
|
||||
clean[header] = value
|
||||
return clean
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> bytes:
|
||||
limit = (
|
||||
HTTP_MAX_RESPONSE_BYTES if max_response_bytes is None else max_response_bytes
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
raise ValueError("HTTP response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
|
||||
if timeout_ms is None:
|
||||
return default
|
||||
if timeout_ms <= 0:
|
||||
return None
|
||||
return timeout_ms / 1000
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
3
lnbits/core/wasm_ext/routes/__init__.py
Normal file
3
lnbits/core/wasm_ext/routes/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .register import register_wasm_extension, unregister_wasm_extension
|
||||
|
||||
__all__ = ["register_wasm_extension", "unregister_wasm_extension"]
|
||||
396
lnbits/core/wasm_ext/routes/api.py
Normal file
396
lnbits/core/wasm_ext/routes/api.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.core.services.extensions import get_wasm_runtime_limits_for_extension
|
||||
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
|
||||
from lnbits.decorators import check_access_token, check_account_exists
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.config import WasmAPIRouteConfig
|
||||
from ..wasm.invoke import invoke_wasm_extension_export
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .open_api import wasm_extension_api_openapi_metadata, wasm_extension_api_tag
|
||||
|
||||
_WASM_EXTENSION_API_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
class WasmRequestBodyTooLargeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmRoutePayload:
|
||||
data: dict[str, Any]
|
||||
request_bytes: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmAPIRouteRegistration:
|
||||
route_config: WasmAPIRouteConfig
|
||||
method: str
|
||||
route_path: str
|
||||
export_name: str
|
||||
path_params: dict[str, str]
|
||||
auth: str
|
||||
route_name: str
|
||||
|
||||
|
||||
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
route_registrations = [
|
||||
_wasm_extension_api_route_registration(extension, route_config)
|
||||
for route_config in extension.config.api_routes
|
||||
]
|
||||
|
||||
openapi_schema_changed = _remove_wasm_extension_api_routes(app, extension.id)
|
||||
for route_registration in route_registrations:
|
||||
if _add_wasm_extension_api_route(app, extension, route_registration):
|
||||
openapi_schema_changed = True
|
||||
if openapi_schema_changed:
|
||||
app.openapi_schema = None
|
||||
|
||||
|
||||
def unregister_wasm_extension_api_routes(app: FastAPI, ext_id: str) -> bool:
|
||||
openapi_schema_changed = _remove_wasm_extension_api_routes(app, ext_id)
|
||||
if openapi_schema_changed:
|
||||
app.openapi_schema = None
|
||||
return openapi_schema_changed
|
||||
|
||||
|
||||
def _add_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_registration: WasmAPIRouteRegistration,
|
||||
) -> bool:
|
||||
method = route_registration.method
|
||||
route_path = route_registration.route_path
|
||||
export_name = route_registration.export_name
|
||||
path_params = route_registration.path_params
|
||||
auth = route_registration.auth
|
||||
route_name = route_registration.route_name
|
||||
route_config = route_registration.route_config
|
||||
openapi = wasm_extension_api_openapi_metadata(extension, route_config, method)
|
||||
|
||||
if not _prepare_wasm_extension_api_route(app, route_path, method, route_name):
|
||||
return False
|
||||
|
||||
async def invoke_wasm_api_request(
|
||||
request: Request,
|
||||
account: Account | None = None,
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
limits = await get_wasm_runtime_limits_for_extension(extension.id)
|
||||
payload = await _read_api_payload(
|
||||
request,
|
||||
path_params,
|
||||
max_body_bytes=limits["wasm_runtime_max_request_bytes"],
|
||||
)
|
||||
owner_id = await _wasm_route_owner_id(extension, route_config, payload)
|
||||
return await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
payload.data,
|
||||
user=account,
|
||||
access_token=access_token,
|
||||
context="event" if owner_id else "user",
|
||||
owner_id=owner_id,
|
||||
trigger_type="http",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
request_bytes=payload.request_bytes,
|
||||
context_data={"origin": _request_origin(request)},
|
||||
)
|
||||
except WasmRequestBodyTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
async def invoke_private_wasm_extension_export(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request, account, access_token)
|
||||
|
||||
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
invoke_public_wasm_extension_export
|
||||
if auth == "public"
|
||||
else invoke_private_wasm_extension_export
|
||||
),
|
||||
methods=[method],
|
||||
name=route_name,
|
||||
tags=[wasm_extension_api_tag(extension)],
|
||||
summary=openapi.summary,
|
||||
description=openapi.description,
|
||||
operation_id=openapi.operation_id,
|
||||
openapi_extra=openapi.openapi_extra,
|
||||
include_in_schema=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def _read_api_payload(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> WasmRoutePayload:
|
||||
payload = _read_api_path_params(request, path_params)
|
||||
payload.update(_read_api_query_params(request))
|
||||
request_bytes: int | None = None
|
||||
if request.method in {"POST", "PUT", "PATCH"}:
|
||||
body, request_bytes = await _read_json_object_with_size(
|
||||
request,
|
||||
max_body_bytes=max_body_bytes,
|
||||
)
|
||||
payload.update(body)
|
||||
return WasmRoutePayload(payload, request_bytes)
|
||||
|
||||
|
||||
async def _wasm_route_owner_id(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
payload: WasmRoutePayload,
|
||||
) -> str | None:
|
||||
owner_context = route_config.owner_context
|
||||
if not owner_context:
|
||||
return None
|
||||
source_id = payload.data.get(owner_context.id_param)
|
||||
if not isinstance(source_id, str) or not source_id:
|
||||
raise PermissionError("WASM owner-context route source is missing.")
|
||||
owner_id = await storage_get_row_owner_id(
|
||||
extension.id,
|
||||
owner_context.table,
|
||||
source_id,
|
||||
)
|
||||
if not owner_id:
|
||||
raise PermissionError("WASM owner-context route source was not found.")
|
||||
return owner_id
|
||||
|
||||
|
||||
async def _read_json_object(
|
||||
request: Request,
|
||||
*,
|
||||
max_body_bytes: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body, _ = await _read_json_object_with_size(
|
||||
request,
|
||||
max_body_bytes=(
|
||||
settings.wasm_runtime_max_request_bytes
|
||||
if max_body_bytes is None
|
||||
else max_body_bytes
|
||||
),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def _read_json_object_with_size(
|
||||
request: Request,
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
body = await _read_limited_body(request, max_body_bytes=max_body_bytes)
|
||||
if not body:
|
||||
return {}, 0
|
||||
value = json.loads(body)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("WASM extension API payload must be a JSON object.")
|
||||
return value, len(body)
|
||||
|
||||
|
||||
async def _read_limited_body(request: Request, *, max_body_bytes: int) -> bytes:
|
||||
content_length = _request_content_length(request)
|
||||
if _wasm_request_too_large(content_length, max_body_bytes):
|
||||
raise WasmRequestBodyTooLargeError(
|
||||
f"WASM extension request is too large: {content_length} bytes."
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in request.stream():
|
||||
if not chunk:
|
||||
continue
|
||||
size += len(chunk)
|
||||
if _wasm_request_too_large(size, max_body_bytes):
|
||||
raise WasmRequestBodyTooLargeError(
|
||||
f"WASM extension request is too large: {size} bytes."
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _read_api_path_params(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in request.path_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _read_api_query_params(request: Request) -> dict[str, Any]:
|
||||
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
|
||||
|
||||
|
||||
def _request_content_length(request: Request) -> int | None:
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and content_length.isdigit():
|
||||
return int(content_length)
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_request_too_large(size: int | None, max_body_bytes: int) -> bool:
|
||||
return size is not None and max_body_bytes > 0 and size > max_body_bytes
|
||||
|
||||
|
||||
def _request_origin(request: Request) -> str | None:
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return None
|
||||
return origin[:256]
|
||||
|
||||
|
||||
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
|
||||
if not isinstance(export_name, str) or not export_name:
|
||||
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
|
||||
|
||||
for export in extension.exports:
|
||||
if export.name != export_name:
|
||||
continue
|
||||
if export.visibility in {"public", "authenticated"}:
|
||||
return export_name
|
||||
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
|
||||
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
|
||||
|
||||
|
||||
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
|
||||
if not isinstance(method, str):
|
||||
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
|
||||
method = method.upper()
|
||||
if method not in _WASM_EXTENSION_API_METHODS:
|
||||
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
|
||||
return method
|
||||
|
||||
|
||||
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return f"/api/v1/ext/{extension.id}"
|
||||
return f"/api/v1/ext/{extension.id}{path}"
|
||||
|
||||
|
||||
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
|
||||
if auth in {"public", "user"}:
|
||||
return auth
|
||||
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
|
||||
|
||||
|
||||
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) != route_path:
|
||||
continue
|
||||
methods = getattr(route, "methods", set()) or set()
|
||||
if method in methods:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_extension_api_route_registration(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
) -> WasmAPIRouteRegistration:
|
||||
method = _wasm_extension_api_method(extension, route_config.method)
|
||||
route_path = _wasm_extension_api_path(extension, route_config.path)
|
||||
return WasmAPIRouteRegistration(
|
||||
route_config=route_config,
|
||||
method=method,
|
||||
route_path=route_path,
|
||||
export_name=_wasm_extension_api_export(extension, route_config.export),
|
||||
path_params=route_config.path_params,
|
||||
auth=_wasm_extension_route_auth(extension, route_config.auth),
|
||||
route_name=_wasm_extension_api_route_name(extension.id, method, route_path),
|
||||
)
|
||||
|
||||
|
||||
def _remove_wasm_extension_api_routes(app: FastAPI, ext_id: str) -> bool:
|
||||
removed = False
|
||||
for route in list(app.router.routes):
|
||||
if not _is_wasm_extension_api_route(route, ext_id):
|
||||
continue
|
||||
app.router.routes.remove(route)
|
||||
removed = True
|
||||
return removed
|
||||
|
||||
|
||||
def _is_wasm_extension_api_route(route: Any, ext_id: str) -> bool:
|
||||
route_name = getattr(route, "name", None)
|
||||
if not isinstance(route_name, str) or not route_name.startswith(f"{ext_id}:"):
|
||||
return False
|
||||
route_name_parts = route_name.split(":", 2)
|
||||
if len(route_name_parts) != 3:
|
||||
return False
|
||||
_, route_method, named_route_path = route_name_parts
|
||||
if route_method not in _WASM_EXTENSION_API_METHODS:
|
||||
return False
|
||||
|
||||
route_path = getattr(route, "path", None)
|
||||
if not isinstance(route_path, str):
|
||||
return False
|
||||
|
||||
route_prefix = f"/api/v1/ext/{ext_id}"
|
||||
if route_path != route_prefix and not route_path.startswith(f"{route_prefix}/"):
|
||||
return False
|
||||
return named_route_path == route_path
|
||||
|
||||
|
||||
def _prepare_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
route_path: str,
|
||||
method: str,
|
||||
route_name: str,
|
||||
) -> bool:
|
||||
for route in list(app.router.routes):
|
||||
if getattr(route, "path", None) != route_path:
|
||||
continue
|
||||
methods = getattr(route, "methods", set()) or set()
|
||||
if method not in methods:
|
||||
continue
|
||||
if getattr(route, "name", None) != route_name:
|
||||
return False
|
||||
app.router.routes.remove(route)
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _wasm_extension_api_route_name(ext_id: str, method: str, route_path: str) -> str:
|
||||
return f"{ext_id}:{method}:{route_path}"
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _path_template_pattern(path: str) -> str:
|
||||
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
||||
return f"^{pattern}$"
|
||||
140
lnbits/core/wasm_ext/routes/assets.py
Normal file
140
lnbits/core/wasm_ext/routes/assets.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import PathLike as StaticFilesPathLike
|
||||
from starlette.types import Scope
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
|
||||
WASM_EXTENSION_CORE_STATIC_ASSETS = {
|
||||
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
|
||||
"material-icons-v50.woff2": (
|
||||
"static/fonts/material-icons-v50.woff2",
|
||||
"font/woff2",
|
||||
),
|
||||
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
|
||||
"quasar.umd.prod.js": (
|
||||
"static/vendor/quasar.umd.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"qrcode.vue.browser.js": (
|
||||
"static/vendor/qrcode.vue.browser.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"vue.global.prod.js": (
|
||||
"static/vendor/vue.global.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
}
|
||||
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
|
||||
"material-icons.css": (
|
||||
"""
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./material-icons-v50.woff2') format('woff2');
|
||||
}
|
||||
""",
|
||||
"text/css; charset=utf-8",
|
||||
)
|
||||
}
|
||||
WASM_EXTENSION_STATIC_MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".ogg": "audio/ogg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
|
||||
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
|
||||
|
||||
|
||||
class GuardedWasmExtensionStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
|
||||
return _wasm_extension_core_asset_response(path)
|
||||
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
|
||||
raise HTTPException(status_code=404)
|
||||
return await super().get_response(path, scope)
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path: StaticFilesPathLike,
|
||||
stat_result: os.stat_result,
|
||||
scope: Scope,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
suffix = Path(full_path).suffix.lower()
|
||||
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
|
||||
_reject_html_like_wasm_static_asset(Path(full_path))
|
||||
|
||||
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
|
||||
static_path = extension.root_path / "static"
|
||||
|
||||
mount_path = f"/ext-assets/{extension.id}"
|
||||
if any(getattr(route, "path", None) == mount_path for route in app.routes):
|
||||
return
|
||||
|
||||
app.mount(
|
||||
mount_path,
|
||||
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
|
||||
name=f"{extension.id}-static",
|
||||
)
|
||||
|
||||
|
||||
def _reject_html_like_wasm_static_asset(path: Path) -> None:
|
||||
with path.open("rb") as asset_file:
|
||||
prefix = asset_file.read(512).lstrip().lower()
|
||||
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
def _wasm_extension_core_asset_response(path: str) -> Response:
|
||||
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
|
||||
if not asset_name or "/" in asset_name or "\\" in asset_name:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
|
||||
if generated_asset:
|
||||
content, content_type = generated_asset
|
||||
response = Response(content=content, media_type=content_type)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
|
||||
if not asset_config:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
relative_path, content_type = asset_config
|
||||
asset_path = Path(settings.lnbits_path, relative_path)
|
||||
if not asset_path.is_file():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
response = FileResponse(asset_path)
|
||||
response.headers["Content-Type"] = content_type
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
356
lnbits/core/wasm_ext/routes/open_api.py
Normal file
356
lnbits/core/wasm_ext/routes/open_api.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ..wasm.config import WasmAPIRouteConfig
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmOpenAPIMetadata:
|
||||
summary: str
|
||||
description: str | None
|
||||
operation_id: str
|
||||
openapi_extra: dict[str, Any] | None
|
||||
|
||||
|
||||
_MISSING_OPENAPI_EXAMPLE = object()
|
||||
|
||||
|
||||
def wasm_extension_api_tag(extension: WasmExtension) -> str:
|
||||
return extension.name.strip() or extension.id
|
||||
|
||||
|
||||
def wasm_extension_api_openapi_metadata(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
method: str,
|
||||
) -> WasmOpenAPIMetadata:
|
||||
operation = _load_wasm_extension_openapi_operation(
|
||||
extension,
|
||||
route_config,
|
||||
)
|
||||
summary = _openapi_string(operation.pop("summary", None)) or (
|
||||
f"{method} {route_config.path}"
|
||||
)
|
||||
description = _openapi_string(operation.pop("description", None))
|
||||
operation_id = (
|
||||
_openapi_string(operation.pop("operationId", None))
|
||||
or _openapi_string(operation.pop("operation_id", None))
|
||||
or _wasm_extension_default_operation_id(extension, route_config, method)
|
||||
)
|
||||
operation.pop("tags", None)
|
||||
_add_wasm_openapi_success_examples(operation)
|
||||
return WasmOpenAPIMetadata(
|
||||
summary=summary,
|
||||
description=description,
|
||||
operation_id=operation_id,
|
||||
openapi_extra=operation or None,
|
||||
)
|
||||
|
||||
|
||||
def _load_wasm_extension_openapi_operation(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
openapi_refs = _wasm_extension_openapi_refs(extension, route_config)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Ignoring OpenAPI metadata for WASM extension '{extension.id}' "
|
||||
f"route '{route_config.path}': {exc}"
|
||||
)
|
||||
return {}
|
||||
if not openapi_refs:
|
||||
return {}
|
||||
|
||||
errors: list[Exception] = []
|
||||
for openapi_ref in openapi_refs:
|
||||
try:
|
||||
document_path, pointer = _wasm_openapi_ref_parts(openapi_ref)
|
||||
document = _load_wasm_openapi_document(extension, document_path)
|
||||
operation = _resolve_json_pointer(document, pointer)
|
||||
if not isinstance(operation, dict):
|
||||
raise TypeError("OpenAPI route fragment must resolve to an object.")
|
||||
return _inline_wasm_openapi_refs(deepcopy(operation), document)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
logger.warning(
|
||||
f"Ignoring OpenAPI metadata for WASM extension '{extension.id}' "
|
||||
f"route '{route_config.path}': {errors[-1]}"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _wasm_extension_openapi_refs(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
) -> list[str]:
|
||||
if route_config.openapi:
|
||||
return [
|
||||
_wasm_openapi_resolved_ref(
|
||||
extension.config.openapi,
|
||||
route_config.openapi,
|
||||
)
|
||||
]
|
||||
|
||||
document_path = extension.config.openapi
|
||||
if not document_path:
|
||||
return []
|
||||
|
||||
document_path = _wasm_openapi_document_path(document_path)
|
||||
route_keys = [route_config.export, _wasm_openapi_route_key(route_config.export)]
|
||||
return [
|
||||
f"{document_path}#/routes/{_json_pointer_token(route_key)}"
|
||||
for route_key in dict.fromkeys(route_keys)
|
||||
]
|
||||
|
||||
|
||||
def _wasm_openapi_resolved_ref(
|
||||
base_ref: str | None,
|
||||
route_ref: str,
|
||||
) -> str:
|
||||
if not route_ref.startswith("#"):
|
||||
return route_ref
|
||||
if not base_ref:
|
||||
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
|
||||
return f"{_wasm_openapi_document_path(base_ref)}{route_ref}"
|
||||
|
||||
|
||||
def _wasm_openapi_document_path(openapi_ref: str) -> str:
|
||||
document_path, _, _ = openapi_ref.partition("#")
|
||||
if not document_path:
|
||||
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
|
||||
return document_path
|
||||
|
||||
|
||||
def _wasm_openapi_route_key(export_name: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9]+", "_", export_name).strip("_").lower()
|
||||
|
||||
|
||||
def _json_pointer_token(value: str) -> str:
|
||||
return value.replace("~", "~0").replace("/", "~1")
|
||||
|
||||
|
||||
def _wasm_openapi_ref_parts(openapi_ref: str) -> tuple[str, str]:
|
||||
document_path, _, pointer = openapi_ref.partition("#")
|
||||
if not document_path:
|
||||
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
|
||||
return document_path, pointer
|
||||
|
||||
|
||||
def _load_wasm_openapi_document(
|
||||
extension: WasmExtension,
|
||||
document_path: str,
|
||||
) -> dict[str, Any]:
|
||||
if "://" in document_path or document_path.startswith(("/", "\\")):
|
||||
raise ValueError("OpenAPI metadata reference must be a local relative path.")
|
||||
if not document_path.lower().endswith(".json"):
|
||||
raise ValueError("OpenAPI metadata reference must point to a JSON file.")
|
||||
|
||||
extension_root = extension.root_path.resolve()
|
||||
path = (extension_root / document_path).resolve()
|
||||
if not path.is_relative_to(extension_root):
|
||||
raise ValueError("OpenAPI metadata reference escapes the extension root.")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"OpenAPI metadata file not found: {document_path}")
|
||||
|
||||
with path.open("r", encoding="utf-8") as openapi_file:
|
||||
document = json.load(openapi_file)
|
||||
if not isinstance(document, dict):
|
||||
raise TypeError("OpenAPI metadata file must contain a JSON object.")
|
||||
return document
|
||||
|
||||
|
||||
def _resolve_json_pointer(document: Any, pointer: str) -> Any:
|
||||
if not pointer:
|
||||
return document
|
||||
if not pointer.startswith("/"):
|
||||
raise ValueError("OpenAPI metadata reference must use a JSON pointer.")
|
||||
|
||||
value = document
|
||||
for raw_token in pointer[1:].split("/"):
|
||||
token = raw_token.replace("~1", "/").replace("~0", "~")
|
||||
if isinstance(value, dict):
|
||||
value = value[token]
|
||||
elif isinstance(value, list):
|
||||
value = value[int(token)]
|
||||
else:
|
||||
raise KeyError(token)
|
||||
return value
|
||||
|
||||
|
||||
def _inline_wasm_openapi_refs(
|
||||
value: Any,
|
||||
document: dict[str, Any],
|
||||
seen_refs: tuple[str, ...] = (),
|
||||
) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [_inline_wasm_openapi_refs(item, document, seen_refs) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
ref = value.get("$ref")
|
||||
if isinstance(ref, str) and ref.startswith("#/") and ref not in seen_refs:
|
||||
try:
|
||||
resolved = _inline_wasm_openapi_refs(
|
||||
deepcopy(_resolve_json_pointer(document, ref[1:])),
|
||||
document,
|
||||
(*seen_refs, ref),
|
||||
)
|
||||
except Exception:
|
||||
resolved = None
|
||||
|
||||
if resolved is not None:
|
||||
overrides = {
|
||||
key: _inline_wasm_openapi_refs(item, document, seen_refs)
|
||||
for key, item in value.items()
|
||||
if key != "$ref"
|
||||
}
|
||||
if isinstance(resolved, dict):
|
||||
return {**resolved, **overrides}
|
||||
if not overrides:
|
||||
return resolved
|
||||
|
||||
return {
|
||||
key: _inline_wasm_openapi_refs(item, document, seen_refs)
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
def _add_wasm_openapi_success_examples(operation: dict[str, Any]) -> None:
|
||||
responses = operation.get("responses")
|
||||
if not isinstance(responses, dict):
|
||||
return
|
||||
|
||||
for response in responses.values():
|
||||
if not isinstance(response, dict):
|
||||
continue
|
||||
content = response.get("content")
|
||||
if not isinstance(content, dict):
|
||||
continue
|
||||
json_content = content.get("application/json")
|
||||
if not isinstance(json_content, dict):
|
||||
continue
|
||||
if "example" in json_content or "examples" in json_content:
|
||||
continue
|
||||
|
||||
schema = json_content.get("schema")
|
||||
example = _wasm_openapi_success_example(schema)
|
||||
if example is not None:
|
||||
json_content["example"] = example
|
||||
|
||||
|
||||
def _wasm_openapi_success_example(schema: Any) -> Any | None:
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
|
||||
for keyword in ("oneOf", "anyOf"):
|
||||
variants = schema.get(keyword)
|
||||
if not isinstance(variants, list):
|
||||
continue
|
||||
for variant in variants:
|
||||
if _wasm_openapi_schema_has_ok_value(variant, True):
|
||||
return _wasm_openapi_schema_example(variant)
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_openapi_schema_has_ok_value(schema: Any, ok_value: bool) -> bool:
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
properties = schema.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
ok_schema = properties.get("ok")
|
||||
if isinstance(ok_schema, dict):
|
||||
enum = ok_schema.get("enum")
|
||||
return isinstance(enum, list) and ok_value in enum
|
||||
all_of = schema.get("allOf")
|
||||
return isinstance(all_of, list) and any(
|
||||
_wasm_openapi_schema_has_ok_value(item, ok_value) for item in all_of
|
||||
)
|
||||
|
||||
|
||||
def _wasm_openapi_schema_example(schema: Any) -> Any:
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
|
||||
explicit_example = _wasm_openapi_explicit_schema_example(schema)
|
||||
if explicit_example is not _MISSING_OPENAPI_EXAMPLE:
|
||||
return explicit_example
|
||||
|
||||
composed_example = _wasm_openapi_composed_schema_example(schema)
|
||||
if composed_example is not _MISSING_OPENAPI_EXAMPLE:
|
||||
return composed_example
|
||||
|
||||
return _wasm_openapi_type_schema_example(schema)
|
||||
|
||||
|
||||
def _wasm_openapi_explicit_schema_example(schema: dict[str, Any]) -> Any:
|
||||
if "example" in schema:
|
||||
return schema["example"]
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list) and enum:
|
||||
return enum[0]
|
||||
return _MISSING_OPENAPI_EXAMPLE
|
||||
|
||||
|
||||
def _wasm_openapi_composed_schema_example(schema: dict[str, Any]) -> Any:
|
||||
all_of = schema.get("allOf")
|
||||
if isinstance(all_of, list):
|
||||
example: dict[str, Any] = {}
|
||||
for item in all_of:
|
||||
item_example = _wasm_openapi_schema_example(item)
|
||||
if isinstance(item_example, dict):
|
||||
example.update(item_example)
|
||||
return example
|
||||
|
||||
for keyword in ("oneOf", "anyOf"):
|
||||
variants = schema.get(keyword)
|
||||
if isinstance(variants, list) and variants:
|
||||
return _wasm_openapi_schema_example(variants[0])
|
||||
return _MISSING_OPENAPI_EXAMPLE
|
||||
|
||||
|
||||
def _wasm_openapi_type_schema_example(schema: dict[str, Any]) -> Any:
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "object" or isinstance(schema.get("properties"), dict):
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return {}
|
||||
return {
|
||||
name: _wasm_openapi_schema_example(property_schema)
|
||||
for name, property_schema in properties.items()
|
||||
}
|
||||
if schema_type == "array":
|
||||
return [_wasm_openapi_schema_example(schema.get("items"))]
|
||||
if schema_type == "integer":
|
||||
return 0
|
||||
if schema_type == "number":
|
||||
return 0
|
||||
if schema_type == "boolean":
|
||||
return True
|
||||
return "string"
|
||||
|
||||
|
||||
def _openapi_string(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _wasm_extension_default_operation_id(
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
method: str,
|
||||
) -> str:
|
||||
value = f"{extension.id}_{method}_{route_config.path}"
|
||||
value = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()
|
||||
return value or f"{extension.id}_{method.lower()}"
|
||||
53
lnbits/core/wasm_ext/routes/register.py
Normal file
53
lnbits/core/wasm_ext/routes/register.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.component import warm_wasm_extension
|
||||
from ..wasm.loader import WasmExtension, load_wasm_extension
|
||||
from .api import (
|
||||
register_wasm_extension_api_routes,
|
||||
unregister_wasm_extension_api_routes,
|
||||
)
|
||||
from .assets import mount_wasm_extension_static
|
||||
from .ui import register_wasm_extension_ui_routes
|
||||
|
||||
|
||||
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
|
||||
load_started_at = perf_counter()
|
||||
loaded = load_wasm_extension(ext_id)
|
||||
core_app_extra.wasm_extension_registry.require_available(loaded)
|
||||
|
||||
warm_wasm_extension(loaded)
|
||||
mount_wasm_extension_static(app, loaded)
|
||||
register_wasm_extension_ui_routes(app, loaded)
|
||||
register_wasm_extension_api_routes(app, loaded)
|
||||
|
||||
core_app_extra.wasm_extension_registry.register(loaded)
|
||||
|
||||
settings.activate_extension_paths(ext_id, [])
|
||||
module_size = _format_wasm_extension_size(loaded.module_path.stat().st_size)
|
||||
load_seconds = perf_counter() - load_started_at
|
||||
logger.info(
|
||||
f"Loaded WASM extension '{loaded.id}' "
|
||||
f"({module_size}) in {load_seconds:.2f} s."
|
||||
)
|
||||
return loaded
|
||||
|
||||
|
||||
def unregister_wasm_extension(app: FastAPI, ext_id: str) -> None:
|
||||
routes_removed = unregister_wasm_extension_api_routes(app, ext_id)
|
||||
core_app_extra.wasm_extension_registry.unregister(ext_id)
|
||||
if routes_removed:
|
||||
logger.info(f"Unloaded WASM extension API routes for '{ext_id}'.")
|
||||
|
||||
|
||||
def _format_wasm_extension_size(size_bytes: int) -> str:
|
||||
if size_bytes >= 1_000_000:
|
||||
return f"{size_bytes / 1_000_000:,.2f} MB"
|
||||
return f"{size_bytes / 1_000:,.2f} KB"
|
||||
127
lnbits/core/wasm_ext/routes/security.py
Normal file
127
lnbits/core/wasm_ext/routes/security.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
|
||||
|
||||
|
||||
def wasm_extension_wrapper_response(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
auth: str,
|
||||
user_json: str | None,
|
||||
) -> Any:
|
||||
public = auth == "public"
|
||||
response = template_renderer().TemplateResponse(
|
||||
request,
|
||||
"wasm_extension.html",
|
||||
{
|
||||
"extension": extension,
|
||||
"public": public,
|
||||
"user": user_json,
|
||||
},
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
return response
|
||||
|
||||
|
||||
def wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
extension_assets = f"{origin}/ext-assets/{extension.id}/"
|
||||
return (
|
||||
"sandbox allow-scripts allow-pointer-lock; "
|
||||
"default-src 'none'; "
|
||||
f"script-src {extension_assets}; "
|
||||
"script-src-attr 'none'; "
|
||||
f"style-src {extension_assets}; "
|
||||
"style-src-attr 'none'; "
|
||||
f"img-src {extension_assets} data:; "
|
||||
f"font-src {extension_assets}; "
|
||||
"connect-src 'none'; "
|
||||
"form-action 'none'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"frame-src 'none'; "
|
||||
"worker-src 'none'; "
|
||||
f"media-src {extension_assets}; "
|
||||
"manifest-src 'none'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
|
||||
def wasm_extension_frame_url(
|
||||
extension: WasmExtension, frame_path: str, user_id: str | None
|
||||
) -> str:
|
||||
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
|
||||
return f"{frame_path}?frame_token={token}"
|
||||
|
||||
|
||||
def consume_wasm_extension_frame_token(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
token = request.query_params.get("frame_token")
|
||||
if not token:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
|
||||
|
||||
cache_key = _wasm_extension_frame_token_cache_key(token)
|
||||
token_data = cache.get(cache_key)
|
||||
if (
|
||||
not isinstance(token_data, dict)
|
||||
or token_data.get("extension_id") != extension.id
|
||||
or token_data.get("frame_path") != frame_path
|
||||
):
|
||||
_raise_wasm_extension_frame_not_found(
|
||||
extension, frame_path, "unknown or expired"
|
||||
)
|
||||
|
||||
token_user_id = token_data.get("user_id")
|
||||
if token_user_id and token_user_id != user_id:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
|
||||
|
||||
cache.pop(cache_key)
|
||||
|
||||
|
||||
def _create_wasm_extension_frame_token(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> str:
|
||||
token = uuid4().hex
|
||||
cache.set(
|
||||
_wasm_extension_frame_token_cache_key(token),
|
||||
{
|
||||
"extension_id": extension.id,
|
||||
"frame_path": frame_path,
|
||||
"user_id": user_id,
|
||||
},
|
||||
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _wasm_extension_frame_token_cache_key(token: str) -> str:
|
||||
return f"wasm-frame-token:{token}"
|
||||
|
||||
|
||||
def _raise_wasm_extension_frame_not_found(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
reason: str,
|
||||
) -> NoReturn:
|
||||
logger.warning(
|
||||
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
369
lnbits/core/wasm_ext/routes/ui.py
Normal file
369
lnbits/core/wasm_ext/routes/ui.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import UUID4
|
||||
|
||||
from lnbits.core.crud import get_installed_extension, get_user_from_account
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_account_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_has_route,
|
||||
_path_template_pattern,
|
||||
_read_json_object,
|
||||
_snake_to_camel,
|
||||
_wasm_extension_api_export,
|
||||
_wasm_extension_api_method,
|
||||
_wasm_extension_api_path,
|
||||
_wasm_extension_route_auth,
|
||||
)
|
||||
from .security import (
|
||||
consume_wasm_extension_frame_token,
|
||||
wasm_extension_frame_csp,
|
||||
wasm_extension_frame_url,
|
||||
wasm_extension_wrapper_response,
|
||||
)
|
||||
|
||||
|
||||
def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
_add_wasm_extension_frame_config_route(app, extension)
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.ui_routes):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
|
||||
entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
extension,
|
||||
route_path,
|
||||
auth,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_config_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
) -> None:
|
||||
route_path = _wasm_extension_frame_config_path(extension)
|
||||
if _has_route(app, route_path, "POST"):
|
||||
return
|
||||
|
||||
async def create_wasm_extension_frame_config(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
body = await _read_json_object(request)
|
||||
except WasmRequestBodyTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
|
||||
auth = ui_route["auth"]
|
||||
|
||||
if auth == "user":
|
||||
account = await check_account_exists(request, access_token, usr)
|
||||
user_id: str | None = account.id
|
||||
else:
|
||||
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
||||
|
||||
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
|
||||
|
||||
return _wasm_extension_frame_config(
|
||||
extension,
|
||||
ui_route["frame_path"],
|
||||
auth,
|
||||
ui_route["path_params"],
|
||||
ui_route["route_params"],
|
||||
_read_wasm_extension_route_query(body.get("query")),
|
||||
user_id,
|
||||
granted_permission_ids,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
create_wasm_extension_frame_config,
|
||||
methods=["POST"],
|
||||
name=f"{extension.id}:frame-config",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_wrapper_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_path: str,
|
||||
auth: str,
|
||||
) -> None:
|
||||
if _has_route(app, route_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_private_wasm_extension_page(
|
||||
request: Request,
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> Any:
|
||||
user = await get_user_from_account(account)
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
user.json() if user else None,
|
||||
)
|
||||
|
||||
async def serve_public_wasm_extension_page(request: Request) -> Any:
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
serve_public_wasm_extension_page
|
||||
if auth == "public"
|
||||
else serve_private_wasm_extension_page
|
||||
),
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:{route_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
entrypoint: Path,
|
||||
) -> None:
|
||||
if _has_route(app, frame_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_wasm_extension_frame(
|
||||
request: Request,
|
||||
user_id: str | None = Depends(_optional_wasm_user_id),
|
||||
) -> FileResponse:
|
||||
consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
|
||||
response = FileResponse(entrypoint)
|
||||
response.headers["Content-Security-Policy"] = wasm_extension_frame_csp(
|
||||
request, extension
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
|
||||
# Extension access goes through the parent bridge.
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), "
|
||||
"clipboard-read=(), usb=()"
|
||||
)
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
app.add_api_route(
|
||||
frame_path,
|
||||
serve_wasm_extension_frame,
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:frame:{frame_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_extension_bridge_api_routes(
|
||||
extension: WasmExtension,
|
||||
public: bool,
|
||||
) -> list[dict[str, str]]:
|
||||
routes: list[dict[str, str]] = []
|
||||
for route_config in extension.config.api_routes:
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
if public and auth != "public":
|
||||
continue
|
||||
method = _wasm_extension_api_method(extension, route_config.method)
|
||||
path = _wasm_extension_api_path(extension, route_config.path)
|
||||
_wasm_extension_api_export(extension, route_config.export)
|
||||
routes.append(
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"pattern": _path_template_pattern(path),
|
||||
}
|
||||
)
|
||||
return routes
|
||||
|
||||
|
||||
def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
|
||||
return f"/api/v1/ext/{extension.id}/_ui/frame"
|
||||
|
||||
|
||||
def _match_wasm_extension_ui_route(
|
||||
extension: WasmExtension,
|
||||
path: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.ui_routes):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
|
||||
route_params = _path_template_params(route_path, path)
|
||||
if route_params is None:
|
||||
continue
|
||||
|
||||
return {
|
||||
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
|
||||
"auth": _wasm_extension_route_auth(extension, route_config.auth),
|
||||
"path_params": route_config.path_params,
|
||||
"route_params": route_params,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def _path_template_params(template: str, path: str) -> dict[str, str] | None:
|
||||
template_parts = _path_parts(template)
|
||||
path_parts = _path_parts(path)
|
||||
if len(template_parts) != len(path_parts):
|
||||
return None
|
||||
|
||||
params: dict[str, str] = {}
|
||||
for template_part, path_part in zip(template_parts, path_parts, strict=False):
|
||||
if template_part.startswith("{") and template_part.endswith("}"):
|
||||
param_name = template_part[1:-1]
|
||||
if not param_name:
|
||||
return None
|
||||
params[param_name] = path_part
|
||||
continue
|
||||
|
||||
if template_part != path_part:
|
||||
return None
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _path_parts(path: str) -> list[str]:
|
||||
return [part for part in path.strip("/").split("/") if part]
|
||||
|
||||
|
||||
def _wasm_extension_frame_config(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
auth: str,
|
||||
path_params: dict[str, str],
|
||||
route_params: dict[str, str],
|
||||
query: dict[str, Any],
|
||||
user_id: str | None,
|
||||
permissions: set[str],
|
||||
) -> dict[str, Any]:
|
||||
public = auth == "public"
|
||||
return {
|
||||
"extension": {
|
||||
"id": extension.id,
|
||||
"name": extension.name,
|
||||
},
|
||||
"frameUrl": wasm_extension_frame_url(extension, frame_path, user_id),
|
||||
"bridge": {
|
||||
"extensionId": extension.id,
|
||||
"public": public,
|
||||
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
||||
"query": query,
|
||||
"permissions": sorted(permissions),
|
||||
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _wasm_extension_granted_permission_ids(
|
||||
extension: WasmExtension,
|
||||
) -> set[str]:
|
||||
installed_extension = await get_installed_extension(extension.id)
|
||||
if not installed_extension:
|
||||
return set()
|
||||
return {permission.id for permission in installed_extension.permissions}
|
||||
|
||||
|
||||
def _map_wasm_extension_route_params(
|
||||
route_params: dict[str, str],
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
payload: dict[str, str] = {}
|
||||
for key, value in route_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
|
||||
if not isinstance(query, dict):
|
||||
return {}
|
||||
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in query.items():
|
||||
if value is None:
|
||||
continue
|
||||
payload[_snake_to_camel(str(key))] = value
|
||||
return payload
|
||||
|
||||
|
||||
async def _optional_wasm_user_id(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
return await optional_user_id(request, access_token, usr)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return "/ext"
|
||||
return f"/ext{path}"
|
||||
|
||||
|
||||
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
|
||||
if not isinstance(entrypoint, str) or not entrypoint:
|
||||
raise ValueError(
|
||||
f"Invalid route entrypoint for WASM extension '{extension.id}'."
|
||||
)
|
||||
if entrypoint.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be a "
|
||||
"relative extension path."
|
||||
)
|
||||
|
||||
path = (extension.root_path / entrypoint).resolve()
|
||||
root_path = extension.root_path.resolve()
|
||||
if path != root_path and root_path not in path.parents:
|
||||
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
|
||||
|
||||
static_path = (extension.root_path / "static").resolve()
|
||||
if path == static_path or static_path in path.parents:
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must not be "
|
||||
"inside the static asset directory."
|
||||
)
|
||||
if path.suffix.lower() != ".html":
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be "
|
||||
"an HTML file."
|
||||
)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Route entrypoint not found: {path}")
|
||||
return path
|
||||
25
lnbits/core/wasm_ext/storage/__init__.py
Normal file
25
lnbits/core/wasm_ext/storage/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from .crud import (
|
||||
migrate_wasm_extension_database,
|
||||
storage_append_public_row,
|
||||
storage_count_rows,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_paginated_rows,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_get_row_owner_id,
|
||||
storage_set_row,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"migrate_wasm_extension_database",
|
||||
"storage_append_public_row",
|
||||
"storage_count_rows",
|
||||
"storage_delete_row",
|
||||
"storage_get_paginated_rows",
|
||||
"storage_get_public_paginated_rows",
|
||||
"storage_get_public_row",
|
||||
"storage_get_row",
|
||||
"storage_get_row_owner_id",
|
||||
"storage_set_row",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue