Compare commits

...

74 commits

Author SHA1 Message Date
dependabot[bot]
9495854d5c
chore(deps): bump aiohttp from 3.14.1 to 3.14.3 (#4120)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 08:21:31 +02:00
blackcoffeexbt
43e42eb3b3
Add security.md (#4126)
Some checks failed
codeql / analyze (push) Has been cancelled
2026-08-07 14:16:49 +01:00
dni ⚡
e6c7ebdf42
chore: update to v1.6.0-rc2 (#4121)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
2026-08-05 11:25:12 +01:00
dni ⚡
5592a602da
feat: adds blockexplorer api, frontend via electrum (#4020)
Some checks failed
codeql / analyze (push) Has been cancelled
Co-authored-by: alan <alan@lnbits.com>
2026-08-04 09:50:38 +02:00
Tiago Vasconcelos
a97c75ecdc
feat :add a lightning bolt reaction (#4119) 2026-08-03 15:35:22 +01:00
pretyflaco
02d87445bf
fix: probe Blink fee before paying to avoid max-fee reserve (#4076)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-31 17:09:58 +03:00
Arc
d1a431afc8
fix: harden ambiguous payments (#4110)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-31 14:29:23 +01:00
Vlad Stan
04c130dca3
chore: version bump to 1.6.0-rc1 (#4117) 2026-07-31 16:23:29 +03:00
Vlad Stan
51e585c78c
fix: direct fiat payments (#4116)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2026-07-31 16:22:55 +03:00
Bartok
f981ee2bca
fix(admin): wire misc section + add_field i18n keys (#4111)
Some checks are pending
codeql / analyze (push) Waiting to run
LNbits CI / nix / nix (push) Waiting to run
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-31 10:02:47 +03:00
blackcoffeexbt
e6a61fafbb
Add lightning addresses for wallets in core (#4035)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-31 09:35:18 +03:00
Vlad Stan
bae9e1bf80
fix: extension metadata from non-github sources (#4109)
Some checks failed
codeql / analyze (push) Has been cancelled
2026-07-28 15:12:06 +03:00
Vlad Stan
8b0413fa16
fix: sanitize details_link (#4108)
Co-authored-by: alan <alan@lnbits.com>
2026-07-28 15:11:05 +03:00
dni ⚡
0a7da6dc73
fix: get rid of url rewrites for extensions /upgrades/ (#4022) 2026-07-28 12:03:25 +03:00
blackcoffeexbt
0bdb5dc9a8
Fix funding sources component inclusion in funding.vue (#4096)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
2026-07-27 22:09:35 +03:00
Bartok
37430f6a2a
fix(wallet): use payment_processing i18n key for NFC pay spinner (#4097)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-27 21:01:08 +03:00
dependabot[bot]
fbf43e29fb
chore(deps-dev): bump immutable from 5.1.5 to 5.1.9 (#4086)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 21:00:41 +03:00
Vlad Stan
68429fb364
chore: bump starllet (#4107) 2026-07-27 21:00:08 +03:00
Vlad Stan
7571ebd9a1
chore: bump pillow to 12.3.0 (#4104) 2026-07-27 19:37:30 +03:00
Bartok
9123db3a0b
fix(admin): add missing service_fee i18n keys to en.js (#4095)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-27 19:34:04 +03:00
dependabot[bot]
9aedfdcc9b
chore(deps): bump setuptools from 82.0.1 to 83.0.0 (#4087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 16:59:52 +03:00
Arc
b15478ac2c
Users should be allowed to downgrade payout permission (#4083)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-27 16:46:59 +03:00
Vlad Stan
9b558db477
Show extension type (#4103) 2026-07-27 16:32:57 +03:00
Vlad Stan
e165269ed8
test: support chat tests (#4102) 2026-07-27 15:38:47 +03:00
BrunswickBTC
7258bd32b4
fix: REST contract implementation between lnbits sparkL2 wallet and sparkL2 sidecar (#3952)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2026-07-27 14:10:39 +02:00
Vlad Stan
2b5a513693
fix: check extension type when installing (#4101) 2026-07-27 14:53:12 +03:00
dependabot[bot]
7e2e116d46
chore(deps): bump quasar from 2.19.3 to 2.22.0 (#4098)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: alan <alan@lnbits.com>
2026-07-27 11:28:02 +02:00
Vlad Stan
84da727282
feat: custom wasm install dir (#4100) 2026-07-27 12:27:29 +03:00
dependabot[bot]
5c1cf66ef3
chore(deps): bump form-data from 4.0.5 to 4.0.6 (#4094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:26:49 +02:00
dependabot[bot]
c7d9461749
chore(deps): bump postcss from 8.5.14 to 8.5.23 (#4099)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:26:16 +02:00
Vlad Stan
8723d327fe
feat: add wasm manifest (#4089)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
Co-authored-by: Arc <ben@arc.wales>
2026-07-24 15:35:30 +03:00
Vlad Stan
c0806ca59c
tests: wasm UI tests (#4090) 2026-07-24 10:56:01 +03:00
krvi
d9523aadbd
fix: sort-by button tooltip text (#4093)
Some checks are pending
codeql / analyze (push) Waiting to run
2026-07-23 21:06:15 +03:00
Vlad Stan
dc46bddc80
fix: 12 word mnemonic (#4085) 2026-07-23 20:07:29 +03:00
Bartok
03daf8b218
docs(guide): fix prefared typo and LNbits brand on install links (#4084)
Some checks are pending
codeql / analyze (push) Waiting to run
2026-07-22 16:13:50 +03:00
Vlad Stan
eee372bb3c
fix: sso missing user id (#4082) 2026-07-22 11:54:23 +03:00
Rob
2223733c2d
fix: return preimages for Phoenixd invoices and settled payments (#4068)
Some checks are pending
codeql / analyze (push) Waiting to run
2026-07-21 19:33:22 +03:00
Arc
f3f5c89113
feat: payments breakdown wallet ui + api (#4027)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 18:26:00 +03:00
Vlad Stan
91c8aa77ed
feat(funding): expose allow_self_payment for LND gRPC wallet (#4080)
Co-authored-by: ai-chen2050 <1033467071@qq.com>
2026-07-21 18:06:46 +03:00
volrabp
368d0f548f
fix: auto-create new labels in user account via payments API (#4003) 2026-07-21 17:29:55 +03:00
Pavol Rusnak
afa7e1a2bd
feat: Bark backend support (using barkd) (#4007)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 17:29:18 +03:00
krvi
653924a6dc
i18n: add Faroese (fo) localization (#4067)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 16:57:59 +03:00
Bartok
39e123d391
docs(guide): fix broken Additional Guides links on funding sources table (#4079) 2026-07-21 16:47:13 +03:00
dependabot[bot]
d07088cc44
chore(deps): bump axios from 1.16.0 to 1.18.0 (#4075)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: alan <alan@lnbits.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 16:46:49 +03:00
Tiago Vasconcelos
20115b42aa
feat: display expiration for ACL tokens (#4033)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 15:09:54 +03:00
Arc
4326888a6e
audio, fullscreen, cursor (#4078)
Co-authored-by: alan <alan@lnbits.com>
2026-07-21 13:30:03 +03:00
Bartok
9431cef74c
docs(guide): fix typo enviroment → environment in super user guide (#4073)
Co-authored-by: Sera (Bartok9) <sera@local>
2026-07-21 12:37:43 +03:00
Vlad Stan
c5b5651d8f
feat: add wasm to open api docs (#4074) 2026-07-21 12:35:59 +03:00
Tiago Vasconcelos
9177e2262a
feat: add extra warning for admin keys (#4063)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-21 10:54:59 +03:00
Bartok
b5454f3a18
docs(guide): fix mangled LNPay webhook example markdown (#4071)
Some checks are pending
codeql / analyze (push) Waiting to run
Co-authored-by: Sera (Bartok9) <sera@local>
2026-07-20 15:02:07 +03:00
Vlad Stan
f5989201d9
feat: advanced wasm extension features (#4059) 2026-07-20 14:50:25 +03:00
Morata
a85a5144d0
fix: mark expired incoming invoices as failed and add periodic cleanup (#3867)
Some checks failed
codeql / analyze (push) Has been cancelled
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-16 13:55:53 +03:00
Tiago Vasconcelos
84bf855810
fix: payment filter for time range (#4023) 2026-07-16 13:53:00 +03:00
blackcoffeexbt
09c6e7239d
fix: Infinite invoice listener loop with VoidWallet (#4061)
Some checks failed
codeql / analyze (push) Waiting to run
LNbits CI / nix / nix (push) Has been cancelled
2026-07-15 21:33:48 +03:00
blackcoffeexbt
53a1ae5e53
fix: UX disable "Send" button after clicking when paying LUD16 (#4034) 2026-07-15 17:41:31 +01:00
Vlad Stan
61ed636df0
feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2026-07-15 13:50:19 +03:00
Nathan Day
43900dd6da
feat: add emailConfigured to PublicSettings (#4047)
Some checks failed
codeql / analyze (push) Has been cancelled
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-13 15:28:28 +02:00
Riccardo Balbo
06c553219a
Merge commit from fork
* fix: daily withdrawal limit

* feat: unit tests for cumulative daily withdrawal
2026-07-13 11:59:49 +03:00
Nathan Day
fc73d83bd9
feat: optimise Uvicorn Settings for heavy web socket use (#4028)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
Co-authored-by: dni <office@dnilabs.com>
2026-07-13 10:28:34 +02:00
Riccardo Balbo
4cb743d952
Merge commit from fork 2026-07-13 11:25:08 +03:00
dni ⚡
3b6e87b060
fix: docker image dont rebuild after restart (#4057) 2026-07-13 11:17:28 +03:00
dni ⚡
09e44f18e5
test: fix revolut test timestamp exceeded (#4054) 2026-07-13 10:53:17 +03:00
Vlad Stan
cabb58f8fe
chore: make bundle (#4045)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
2026-07-08 16:59:28 +03:00
Riccardo Balbo
aac04efd0e
Merge commit from fork
* fix: don't show unsafe html in lnurlp notifications

* fix: lnurlp success aes decryption

* regenerate bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-08 16:49:43 +03:00
dni ⚡
fd9009f760
feat: create TaskManager singleton class (#3775)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
2026-07-08 14:07:38 +02:00
dni ⚡
6ab413775a
chore: update to v1.5.6 (#4044)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
2026-07-08 13:54:35 +02:00
Arc
7eb9965205
feat: keep funding source on default reset, force seed backup (#4040)
Co-authored-by: alan <alan@lnbits.com>
2026-07-08 13:54:11 +02:00
dni ⚡
4634ad5a5a
feat: use lnbits price aggregator to save loads of request (#4026)
Some checks failed
codeql / analyze (push) Has been cancelled
Co-authored-by: alan <alan@lnbits.com>
2026-07-06 09:21:55 +02:00
dni ⚡
648aaa17c5
CI: appimage upload-urls with gh upload command (#4036)
Some checks failed
codeql / analyze (push) Has been cancelled
2026-07-03 12:35:14 +01:00
blackcoffeexbt
7db5c986b3
NWC funding source: Reduce polling (#4008)
Some checks are pending
codeql / analyze (push) Waiting to run
2026-07-02 13:00:49 +01:00
Vlad Stan
26c31b626d
refactor: set extension field for payments (#4031)
Some checks failed
codeql / analyze (push) Waiting to run
LNbits CI / nix / nix (push) Has been cancelled
2026-07-01 16:55:15 +03:00
Vlad Stan
e39cb20525
chore: version bump 1.5.5 (#4029)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled
2026-07-01 12:06:55 +03:00
Arc
3cea9a90d1
fix: nix install guide (#4032)
Some checks are pending
codeql / analyze (push) Waiting to run
2026-06-30 14:08:49 +01:00
Arc
3224e2e774
fix: appimage install (#4030) 2026-06-30 15:37:13 +03:00
248 changed files with 33870 additions and 1702 deletions

View file

@ -69,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
@ -80,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=""
@ -144,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

View file

@ -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:
@ -113,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

View file

@ -64,6 +64,11 @@ jobs:
with:
make: openapi
test-e2e:
if: ${{ false }}
needs: [ lint ]
uses: ./.github/workflows/e2e.yml
regtest:
needs: [ lint ]
uses: ./.github/workflows/regtest.yml

41
.github/workflows/e2e.yml vendored Normal file
View 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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]

View file

@ -62,6 +62,10 @@ 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 \
@ -89,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

36
SECURITY.md Normal file
View 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.

View file

@ -118,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

View file

@ -71,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)** — Whats 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

View file

@ -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
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)

View file

@ -42,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**
@ -117,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

View file

@ -41,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) | | |
@ -127,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).
@ -197,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**

View file

@ -23,30 +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,
@ -69,7 +83,7 @@ from .middleware import (
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):
@ -133,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)
@ -166,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
@ -297,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
@ -352,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
@ -381,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
@ -400,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:
@ -412,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(
@ -436,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:
@ -448,54 +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(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
# 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,
)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
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"
)

View file

@ -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

View file

@ -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"]

View file

@ -18,13 +18,13 @@ from .extensions import (
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,
@ -102,7 +102,6 @@ __all__ = [
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
@ -156,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",

View file

@ -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:
@ -144,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)},
)

View file

@ -16,6 +16,7 @@ from ..models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentTotalBreakdown,
PaymentWalletStats,
)
@ -238,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,
@ -290,6 +265,7 @@ 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,
@ -428,6 +404,41 @@ 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,

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -815,3 +815,90 @@ async def m045_add_external_id_to_payments(db: Connection):
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);
""")

View file

@ -23,6 +23,7 @@ from .payments import (
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
@ -83,6 +84,7 @@ __all__ = [
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentTotalBreakdown",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",

View file

@ -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)
@ -61,6 +70,11 @@ class Manifest(BaseModel):
categories: dict[str, list[str]] = {}
class ExtensionManifestType(str, Enum):
PYTHON = "python"
WASM = "wasm"
class GitHubRepoRelease(BaseModel):
name: str
tag_name: str
@ -77,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
@ -84,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
@ -95,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)
@ -118,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:
@ -144,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
@ -190,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
@ -197,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:
@ -260,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,
@ -287,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
@ -348,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:
@ -369,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
@ -400,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
@ -432,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)
@ -468,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):
@ -480,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)
@ -610,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,
@ -642,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
@ -664,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)
@ -672,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
)
@ -691,6 +1010,7 @@ class InstallableExtension(BaseModel):
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
)
@ -717,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:
@ -730,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)
@ -789,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)
@ -800,6 +1129,7 @@ class CreateExtension(BaseModel):
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
permissions: list[ExtensionPermission] = []
class ExtensionDetailsRequest(BaseModel):
@ -834,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}")
@ -852,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
)

View file

@ -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):

View file

@ -54,6 +54,7 @@ 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
@ -220,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
@ -259,6 +267,7 @@ 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
@ -266,6 +275,9 @@ class CreateInvoice(BaseModel):
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):
if v:

View file

@ -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

View file

@ -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",

View 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,
)

View file

@ -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
@ -13,18 +20,174 @@ from lnbits.core.crud import (
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()
@ -38,13 +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)
@ -56,14 +233,10 @@ 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
return Extension.from_installable_ext(ext_info)
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
@ -75,24 +248,423 @@ async def check_extensions_limit(installed_ext: InstallableExtension | None = No
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)
@ -103,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:
@ -120,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
@ -135,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
@ -153,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

View file

@ -20,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(
@ -60,11 +61,7 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
payment.status = PaymentState.SUCCESS.value
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
return fiat_status

View file

@ -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

View 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)

View file

@ -237,6 +237,15 @@ async def send_email(
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):
"""
Dispatches the webhook to the webhook url.

View file

@ -17,8 +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.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
@ -117,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)
@ -214,6 +217,7 @@ 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,
@ -259,6 +263,7 @@ 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,
@ -342,6 +347,7 @@ 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,
@ -370,6 +376,13 @@ 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
@ -513,9 +526,7 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
async def check_wallet_limits(
@ -555,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:
@ -624,7 +634,7 @@ 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)
@ -632,7 +642,7 @@ async def check_transaction_status(
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:
@ -786,10 +796,8 @@ async def _pay_internal_invoice(
) # notify the receiver
# notify receiver asynchronously (extension listeners)
from lnbits.tasks import internal_invoice_queue
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
@ -826,16 +834,15 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
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(
@ -843,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
@ -870,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
payment = await update_payment(payment, conn=conn)
payment = await update_payment(
payment, new_checking_id=new_checking_id, conn=conn
)
return payment
@ -1105,3 +1135,18 @@ async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment
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)

View file

@ -2,77 +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.cache import cache
from lnbits.utils.exchange_rates import btc_rates
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,
@ -89,81 +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))
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)
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)

View file

@ -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(

View file

@ -295,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)
@ -323,7 +326,7 @@ async def api_delete_user_api_token(
async def login_with_sso_provider(
request: Request,
provider: str,
user_id: str | None,
user_id: str | None = None,
auth_user_id: str | None = Depends(optional_user_id),
):
provider_sso = _new_sso(provider)

View 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)

View file

@ -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,13 +120,27 @@ 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, ValueError))
@ -128,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,
@ -251,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:
@ -335,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:
@ -459,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)
@ -535,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:
@ -558,47 +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,
"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,
"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
@ -646,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
)

View file

@ -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,7 +201,9 @@ 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,

View file

@ -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

View file

@ -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,14 +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,
@ -51,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
@ -131,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",
@ -291,9 +307,24 @@ 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.")

View file

@ -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"
)

View file

@ -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

View file

@ -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:

View 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",
]

View 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",
]

View 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)

File diff suppressed because it is too large Load diff

View 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)

View 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

View 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}

View 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)

View 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

View 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

View 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()

View 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"]

View 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
}

View 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
}

View file

@ -0,0 +1,3 @@
from .register import register_wasm_extension, unregister_wasm_extension
__all__ = ["register_wasm_extension", "unregister_wasm_extension"]

View 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}$"

View 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

View 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()}"

View 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"

View 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")

View 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

View 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",
]

View file

@ -0,0 +1,720 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from loguru import logger
from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
from lnbits.settings import settings
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
OWNER_ID_FIELD = "__lnbits_owner_id__"
async def storage_get_row(
ext_id: str,
table: str,
row_id: str,
owner_id: str,
) -> dict[str, Any] | None:
table_schema = _load_table_schema(ext_id, table)
query = f"""
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id, "owner_id": owner_id})
return _row_from_db(table_schema, row) if row else None
async def storage_get_public_row(
ext_id: str,
table: str,
row_id: str,
) -> dict[str, Any] | None:
table_schema = _load_table_schema(ext_id, table)
query = f"""
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id})
return _row_from_db(table_schema, row) if row else None
async def storage_get_row_owner_id(
ext_id: str,
table: str,
row_id: str,
) -> str | None:
_load_table_schema(ext_id, table)
query = f"""
SELECT {OWNER_ID_FIELD} FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id})
owner_id = row[OWNER_ID_FIELD] if row else None
return owner_id if isinstance(owner_id, str) and owner_id else None
async def storage_set_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> None:
table_schema = _load_table_schema(ext_id, table)
clean_data = _data_to_db(table_schema, data, require_id=True)
columns = list(clean_data.keys())
database = Database(f"ext_{ext_id}")
fields = _fields_by_name(table_schema)
placeholders = [
_value_placeholder(database, fields[column], column) for column in columns
]
clean_data[OWNER_ID_FIELD] = owner_id
columns.append(OWNER_ID_FIELD)
placeholders.append(f":{OWNER_ID_FIELD}")
updates = [
f"{column} = excluded.{column}"
for column in columns
if column not in ("id", OWNER_ID_FIELD)
]
conflict_sql = (
"DO UPDATE SET "
+ ", ".join(updates)
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with database.connect() as conn:
await conn.execute(query, clean_data)
async def storage_append_public_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> str:
row_id = uuid4().hex
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
return row_id
async def storage_count_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
) -> int:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(database, table_schema, filters, None, [])
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
query = f"""
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
row = await conn.fetchone(query, values)
return int(row["count"]) if row else 0
async def storage_get_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_delete_row(
ext_id: str,
table: str,
row_id: str,
owner_id: str,
) -> None:
_load_table_schema(ext_id, table)
query = f"""
DELETE FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
await conn.execute(query, {"id": row_id, "owner_id": owner_id})
async def migrate_wasm_extension_database(
ext: InstallableExtension,
current_version: DbVersion | None = None,
) -> None:
migrations_dir = ext.wasm_ext_dir / "storage" / "migrations"
migration_files = _migration_files(migrations_dir)
if not migration_files:
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
return
ext_db = Database(f"ext_{ext.id}")
async with ext_db.connect() as conn:
for version, path in migration_files:
if current_version and version <= current_version.version:
continue
logger.debug(f"running WASM storage migration {ext.id}.{version}")
print(f"running migration {ext.id}.{version}")
await _run_storage_migration(conn, path)
await _update_wasm_migration_version(conn, ext.id, version)
def _migration_files(migrations_dir: Path) -> list[tuple[int, Path]]:
if not migrations_dir.is_dir():
return []
files: list[tuple[int, Path]] = []
for path in migrations_dir.glob("*.json"):
match = _MIGRATION_FILE_RE.match(path.name)
if not match:
raise ValueError(f"Invalid WASM storage migration filename: {path.name}")
files.append((int(match.group(1)), path))
return sorted(files)
async def _run_storage_migration(db: Connection, path: Path) -> None:
migration = _load_json(path)
operations = migration.get("operations")
if not isinstance(operations, list):
raise ValueError(f"WASM storage migration '{path}' has no operations list.")
for operation in operations:
if not isinstance(operation, dict):
raise ValueError(f"WASM storage migration '{path}' has invalid operation.")
sql = _operation_sql(db, operation)
await db.execute(sql)
def _operation_sql(db: Connection, operation: dict[str, Any]) -> str:
op = operation.get("op")
if op == "create_table":
return _create_table_sql(db, operation)
if op == "add_field":
return _add_field_sql(db, operation)
if op == "create_index":
return _create_index_sql(db, operation)
raise ValueError(f"Unsupported WASM storage migration operation: {op}")
def _create_table_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
fields = _require_fields(operation)
if not any(field.get("name") == "id" for field in fields):
raise ValueError(f"WASM storage table '{table}' must define an id field.")
if any(field.get("name") == OWNER_ID_FIELD for field in fields):
raise ValueError(
f"WASM storage table '{table}' defines reserved field '{OWNER_ID_FIELD}'."
)
columns = [
_column_sql(db, field, primary_key=field.get("name") == "id")
for field in fields
]
columns.append(f"{OWNER_ID_FIELD} TEXT NOT NULL")
return f"""
CREATE TABLE IF NOT EXISTS {_table_ref(db, table)} (
{", ".join(columns)}
);
"""
def _add_field_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
field = _field_from_add_field_operation(operation)
if field["name"] == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' cannot add reserved field "
f"'{OWNER_ID_FIELD}'."
)
return f"""
ALTER TABLE {_table_ref(db, table)}
ADD COLUMN {_column_sql(db, field)};
"""
def _create_index_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
name = _require_identifier(operation, "name")
field = _require_identifier(operation, "field")
if field == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' cannot index reserved field "
f"'{OWNER_ID_FIELD}'."
)
if db.type == SQLITE and db.schema:
return f"""
CREATE INDEX IF NOT EXISTS {_schema_ref(db, name)}
ON {table} ({field});
"""
return f"""
CREATE INDEX IF NOT EXISTS {name}
ON {_table_ref(db, table)} ({field});
"""
def _column_sql(
db: Connection,
field: dict[str, Any],
*,
primary_key: bool = False,
) -> str:
name = _require_identifier(field, "name")
column_type = _field_type_sql(db, field)
parts = [name, column_type]
if primary_key:
parts.append("PRIMARY KEY")
elif not field.get("nullable", False):
parts.append("NOT NULL")
if "default" in field:
parts.append(f"DEFAULT {_default_sql(field['default'])}")
return " ".join(parts)
def _field_type_sql(db: Connection, field: dict[str, Any]) -> str:
if field.get("list") is True:
return "TEXT"
field_type = field.get("type")
if field_type == "string":
return "TEXT"
if field_type == "integer":
return db.big_int
if field_type == "number":
return "DOUBLE PRECISION" if db.type == POSTGRES else "REAL"
if field_type == "boolean":
return "BOOLEAN"
if field_type == "datetime":
return "TIMESTAMP"
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
schema = _load_storage_schema(ext_id)
tables = schema.get("tables")
if not isinstance(tables, dict):
raise ValueError(f"WASM extension '{ext_id}' has no storage tables schema.")
_require_identifier({"table": table}, "table")
table_schema = tables.get(table)
if not isinstance(table_schema, dict):
raise ValueError(f"WASM extension '{ext_id}' has no storage table '{table}'.")
fields = table_schema.get("fields")
if not isinstance(fields, list) or not fields:
raise ValueError(f"WASM storage table '{table}' has no fields schema.")
for field in fields:
if not isinstance(field, dict):
raise ValueError(f"WASM storage table '{table}' has invalid field schema.")
_require_identifier(field, "name")
if field["name"] == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' defines reserved field "
f"'{OWNER_ID_FIELD}'."
)
return table_schema
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
schema_path = settings.wasm_extensions_dir / ext_id / "storage" / "schema.json"
if not schema_path.is_file():
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")
return _load_json(schema_path)
def _data_to_db(
table_schema: dict[str, Any],
data: dict[str, Any],
*,
require_id: bool,
) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("WASM storage row data must be an object.")
if require_id and not data.get("id"):
raise ValueError("WASM storage row data must include an id.")
_reject_reserved_owner_field(data, "row")
fields = _fields_by_name(table_schema)
unknown_fields = sorted(set(data) - set(fields))
if unknown_fields:
raise ValueError(
"WASM storage row has unknown fields: " + ", ".join(unknown_fields)
)
return {
field_name: _value_to_db(fields[field_name], value)
for field_name, value in data.items()
}
def _filters_to_db(
table_schema: dict[str, Any],
filters: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(filters, dict):
raise ValueError("WASM storage filters must be an object.")
_reject_reserved_owner_field(filters, "filters")
fields = _fields_by_name(table_schema)
unknown_fields = sorted(set(filters) - set(fields))
if unknown_fields:
raise ValueError(
"WASM storage filters have unknown fields: " + ", ".join(unknown_fields)
)
return {
field_name: _value_to_db(fields[field_name], value)
for field_name, value in filters.items()
}
def _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str:
if field.get("type") == "datetime" and not field.get("list"):
return db.timestamp_placeholder(key)
return f":{key}"
def _where_sql(
db: Compat,
table_schema: dict[str, Any],
filters: dict[str, Any],
search: str | None,
search_fields: list[str],
) -> tuple[str, dict[str, Any]]:
clean_filters = _filters_to_db(table_schema, filters)
fields = _fields_by_name(table_schema)
clauses = [
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
for field in clean_filters
]
values = {f"filter_{field}": value for field, value in clean_filters.items()}
clean_search = search.strip().lower() if search else ""
if clean_search:
fields = _fields_by_name(table_schema)
invalid_fields = sorted(set(search_fields) - set(fields))
if invalid_fields:
raise ValueError(
"WASM storage search has unknown fields: " + ", ".join(invalid_fields)
)
if search_fields:
search_clause = " OR ".join(
f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields
)
clauses.append(f"({search_clause})")
values["search"] = f"%{clean_search}%"
return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values)
def _append_owner_where_sql(where_sql: str) -> str:
owner_clause = f"{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if where_sql:
return f"{where_sql} AND {owner_clause}"
return f"WHERE {owner_clause}"
def _order_sql(
table_schema: dict[str, Any],
sort_by: str | None,
descending: bool,
) -> str:
if not sort_by:
return ""
fields = _fields_by_name(table_schema)
if sort_by not in fields:
raise ValueError(f"WASM storage sort field is unknown: {sort_by}")
direction = "DESC" if descending else "ASC"
return f"ORDER BY {sort_by} {direction}"
def _row_from_db(
table_schema: dict[str, Any],
row: dict[str, Any],
) -> dict[str, Any]:
fields = _fields_by_name(table_schema)
return {
field_name: _value_from_db(fields[field_name], value)
for field_name, value in dict(row).items()
if field_name in fields
}
def _fields_by_name(table_schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
fields = table_schema.get("fields")
if not isinstance(fields, list):
raise ValueError("WASM storage table schema fields must be a list.")
return {field["name"]: field for field in fields}
def _reject_reserved_owner_field(data: dict[str, Any], value_name: str) -> None:
if OWNER_ID_FIELD in data:
raise ValueError(f"WASM storage {value_name} includes a reserved owner field.")
def _value_to_db(field: dict[str, Any], value: Any) -> Any: # noqa: C901
if value is None:
if field.get("nullable", False):
return None
raise ValueError(f"WASM storage field '{field['name']}' cannot be null.")
if field.get("list") is True:
if not isinstance(value, list):
raise ValueError(f"WASM storage field '{field['name']}' must be a list.")
return json.dumps(value)
field_type = field.get("type")
if field_type == "string":
if not isinstance(value, str):
raise ValueError(f"WASM storage field '{field['name']}' must be a string.")
return value
if field_type == "integer":
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"WASM storage field '{field['name']}' must be an integer."
)
return value
if field_type == "number":
if isinstance(value, bool) or not isinstance(value, int | float):
raise ValueError(f"WASM storage field '{field['name']}' must be a number.")
return value
if field_type == "boolean":
if not isinstance(value, bool):
raise ValueError(f"WASM storage field '{field['name']}' must be a boolean.")
return value
if field_type == "datetime":
if isinstance(value, int | float):
return datetime.fromtimestamp(value, tz=timezone.utc)
if isinstance(value, datetime):
return value
raise ValueError(
f"WASM storage field '{field['name']}' must be a Unix timestamp."
)
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
def _value_from_db(field: dict[str, Any], value: Any) -> Any:
if value is None:
return None
if field.get("list") is True:
if isinstance(value, str):
return json.loads(value)
return value
field_type = field.get("type")
if field_type == "boolean":
return bool(value)
if field_type == "datetime":
if isinstance(value, datetime):
return int(value.replace(tzinfo=timezone.utc).timestamp())
if isinstance(value, int | float):
return int(value)
if isinstance(value, str):
try:
return int(datetime.fromisoformat(value).timestamp())
except ValueError:
return value
return value
def _default_sql(value: Any) -> str:
if value is None:
return "NULL"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int | float):
return str(value)
if isinstance(value, str):
return _quote_sql_string(value)
if isinstance(value, list | dict):
return _quote_sql_string(json.dumps(value))
raise ValueError(f"Unsupported WASM storage default value: {value}")
def _field_from_add_field_operation(operation: dict[str, Any]) -> dict[str, Any]:
field = {
"name": operation.get("field"),
"type": operation.get("type"),
}
for key in ("default", "list", "nullable"):
if key in operation:
field[key] = operation[key]
return field
def _require_fields(operation: dict[str, Any]) -> list[dict[str, Any]]:
fields = operation.get("fields")
if not isinstance(fields, list) or not fields:
raise ValueError("WASM storage create_table operation requires fields.")
if not all(isinstance(field, dict) for field in fields):
raise ValueError("WASM storage fields must be objects.")
return fields
def _require_identifier(data: dict[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not _SQL_IDENTIFIER_RE.match(value):
raise ValueError(f"Invalid WASM storage SQL identifier for '{key}': {value}")
return value
def _table_ref(db: Connection, table: str) -> str:
if db.schema:
return f"{_schema_ref(db, table)}"
return table
def _table_ref_for_schema(ext_id: str, table: str) -> str:
_require_identifier({"schema": ext_id}, "schema")
_require_identifier({"table": table}, "table")
return f"{ext_id}.{table}"
def _schema_ref(db: Connection, name: str) -> str:
if not db.schema:
return name
if not _SQL_IDENTIFIER_RE.match(db.schema):
raise ValueError(f"Invalid WASM extension storage schema: {db.schema}")
return f"{db.schema}.{name}"
def _quote_sql_string(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _load_json(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as json_file:
data = json.load(json_file)
if not isinstance(data, dict):
raise ValueError(f"WASM storage migration '{path}' must be a JSON object.")
return data
async def _update_wasm_migration_version(
db: Connection,
ext_id: str,
version: int,
) -> None:
if db.schema is None:
await update_migration_version(db, ext_id, version)
else:
async with core_db.connect() as conn:
await update_migration_version(conn, ext_id, version)

View file

@ -0,0 +1,13 @@
from .component import warm_wasm_extension
from .events import dispatch_wasm_invoice_paid
from .invoke import invoke_wasm_extension_export
from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id
__all__ = [
"WasmExtension",
"dispatch_wasm_invoice_paid",
"invoke_wasm_extension_export",
"is_wasm_extension_dir",
"is_wasm_extension_id",
"warm_wasm_extension",
]

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from wasmtime import Config, Engine
from lnbits.settings import settings
from .loader import WasmExtension
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=8)
def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any:
config = Config()
config.wasm_component_model = True
config.epoch_interruption = True
config.consume_fuel = True
stack_limit = (
settings.wasm_runtime_max_wasm_stack_bytes
if max_wasm_stack_bytes is None
else max_wasm_stack_bytes
)
if stack_limit > 0:
config.max_wasm_stack = stack_limit
return Engine(config)
def _wasm_component(
extension: WasmExtension,
limits: dict[str, int] | None = None,
) -> Any:
stat = extension.module_path.stat()
max_wasm_stack_bytes = (
limits["wasm_runtime_max_wasm_stack_bytes"]
if limits
else settings.wasm_runtime_max_wasm_stack_bytes
)
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
max_wasm_stack_bytes,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
max_wasm_stack_bytes: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(
_wasm_engine(max_wasm_stack_bytes), module_path
)

View file

@ -0,0 +1,123 @@
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import (
BaseModel,
Field,
StrictBool,
StrictStr,
ValidationError,
)
from lnbits.core.models.extensions import ExtensionPermission
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
class _StrictWasmModel(BaseModel):
class Config:
extra = "ignore"
allow_population_by_field_name = True
class WasmExtensionExport(_StrictWasmModel):
name: StrictStr
visibility: Literal["authenticated", "event", "public"]
class WasmRuntimeConfig(_StrictWasmModel):
module: StrictStr
wit: StrictStr | None = None
world: StrictStr = ""
exports: list[WasmExtensionExport] = Field(default_factory=list)
class WasmUIConfig(_StrictWasmModel):
entrypoint: StrictStr | None = None
sandbox: StrictBool | None = None
class WasmSDKConfig(_StrictWasmModel):
frontend_js: StrictStr | None = None
class WasmUIRouteConfig(_StrictWasmModel):
path: StrictStr
entrypoint: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
class WasmRouteOwnerContext(_StrictWasmModel):
table: StrictStr
id_param: StrictStr = Field(..., alias="idParam")
class WasmAPIRouteConfig(_StrictWasmModel):
method: Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
path: StrictStr
export: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
owner_context: WasmRouteOwnerContext | None = Field(None, alias="ownerContext")
openapi: StrictStr | None = None
class WasmEventsConfig(_StrictWasmModel):
on_invoice_paid: StrictStr | None = Field(None, alias="onInvoicePaid")
class WasmExtensionConfig(_StrictWasmModel):
id: StrictStr
name: StrictStr
short_description: StrictStr
tile: StrictStr | None = None
version: StrictStr
min_lnbits_version: StrictStr | None = None
max_lnbits_version: StrictStr | None = None
extension_type: Literal["wasm"]
wasm: WasmRuntimeConfig
events: WasmEventsConfig = Field(
default_factory=lambda: WasmEventsConfig.parse_obj({})
)
ui: WasmUIConfig | None = None
sdk: WasmSDKConfig | None = None
openapi: StrictStr | None = None
ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list)
api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list)
permissions: list[ExtensionPermission] = Field(default_factory=list)
def parse_wasm_extension_config(
ext_id: str,
config: dict[str, Any],
) -> WasmExtensionConfig:
validate_wasm_extension_config_id(ext_id, config)
try:
return WasmExtensionConfig.parse_obj(config)
except ValidationError as exc:
raise ValueError(
f"Invalid WASM extension config for '{ext_id}': {exc}"
) from exc
def validate_wasm_extension_config_id(
ext_id: str,
config: dict[str, Any] | WasmExtensionConfig,
) -> str:
if not _EXTENSION_ID_RE.fullmatch(ext_id):
raise ValueError(f"Invalid WASM extension id '{ext_id}'.")
config_id = (
config.id if isinstance(config, WasmExtensionConfig) else config.get("id")
)
if not isinstance(config_id, str) or not config_id:
raise ValueError(f"WASM extension '{ext_id}' config must define id.")
if config_id != ext_id:
raise ValueError(
f"WASM extension id mismatch: installed as '{ext_id}' "
f"but config declares '{config_id}'."
)
return config_id

View file

@ -0,0 +1,256 @@
import json
from collections.abc import Iterable
from typing import Any
from loguru import logger
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
from lnbits.helpers import sha256s
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
targets: dict[str, tuple[Any, str | None]] = {}
extension_id = _payment_extension_id(payment)
if extension_id:
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if extension:
targets[extension_id] = (
extension,
await _wasm_invoice_paid_owner_id(extension, payment),
)
wallet = await _payment_wallet(payment)
if wallet:
wallet_owner_id = sha256s(wallet.user)
for watch_extension_id in await _wallet_watch_extension_ids(
wallet.user, wallet.id
):
extension = core_app_extra.wasm_extension_registry.get(watch_extension_id)
if not extension:
continue
if watch_extension_id in targets:
existing_extension, existing_owner_id = targets[watch_extension_id]
targets[watch_extension_id] = (
existing_extension,
existing_owner_id or wallet_owner_id,
)
continue
targets[watch_extension_id] = (extension, wallet_owner_id)
for extension, owner_id in targets.values():
await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id)
async def _dispatch_wasm_invoice_paid_to_extension(
extension: Any, payment: Any, owner_id: str | None
) -> None:
export_name = _wasm_invoice_paid_export(extension.config)
if not export_name:
return
if not _is_wasm_event_export(extension, export_name):
logger.warning(
f"WASM extension '{extension.id}' declares invalid onInvoicePaid "
f"export '{export_name}'."
)
return
try:
await invoke_wasm_extension_export(
extension.id,
export_name,
_wasm_invoice_paid_payload(payment),
context="event",
owner_id=owner_id,
trigger_type="event",
event_type="invoice_paid",
wallet_id=payment.wallet_id,
payment_hash=payment.payment_hash,
checking_id=payment.checking_id,
)
except Exception as exc:
logger.warning(
f"WASM extension '{extension.id}' failed to handle paid invoice "
f"'{payment.payment_hash}': {exc!s}"
)
async def _payment_wallet(payment: Any) -> Any | None:
wallet_id = getattr(payment, "wallet_id", None)
if not isinstance(wallet_id, str) or not wallet_id:
return None
try:
return await get_wallet(wallet_id)
except Exception as exc:
logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}")
return None
async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]:
try:
user_extensions = await get_user_extensions(user_id)
except Exception as exc:
logger.warning(
f"Could not fetch extensions for wallet payment watch user "
f"'{user_id}': {exc!s}"
)
return []
extension_ids: list[str] = []
for user_extension in user_extensions:
if not user_extension.active:
continue
if not _has_wallet_watch_grant(user_extension, wallet_id):
continue
if not core_app_extra.wasm_extension_registry.get(user_extension.extension):
continue
try:
installed_extension = await get_installed_extension(
user_extension.extension
)
except Exception as exc:
logger.warning(
f"Could not fetch installed extension '{user_extension.extension}' "
f"for wallet payment watch: {exc!s}"
)
continue
if not installed_extension or not installed_extension.active:
continue
if not _extension_has_permission(
installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION
):
continue
extension_ids.append(user_extension.extension)
return extension_ids
def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool:
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return False
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.enabled and grant.wallet_id == wallet_id:
return True
return False
def _extension_has_permission(extension: Any, permission_id: str) -> bool:
return any(
(
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
== permission_id
for permission in (extension.permissions or [])
)
def _payment_extension_id(payment: Any) -> str | None:
if isinstance(payment.extension, str) and payment.extension:
return payment.extension
extra = payment.extra or {}
tag = extra.get("tag") or payment.tag
return tag if isinstance(tag, str) and tag else None
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
source_id = _payment_source_id(payment)
source_tables = await _wasm_public_invoice_source_tables(extension.id)
if not source_id or not source_tables:
return None
for source_table in source_tables:
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
if owner_id:
return owner_id
return None
def _payment_source_id(payment: Any) -> str | None:
extra = payment.extra or {}
source_id = extra.get("source_id")
return source_id if isinstance(source_id, str) and source_id else None
async def _wasm_public_invoice_source_tables(extension_id: str) -> list[str]:
installed_extension = await get_installed_extension(extension_id)
if not installed_extension:
return []
return _wasm_public_invoice_source_tables_from_permissions(
installed_extension.permissions
)
def _wasm_public_invoice_source_tables_from_permissions(
permissions: Iterable[Any],
) -> list[str]:
for permission in permissions:
permission_id = (
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
if permission_id != "wallet.create_invoice_public":
continue
policies = (
permission.get("policies")
if isinstance(permission, dict)
else getattr(permission, "policies", None)
)
if not isinstance(policies, list):
return []
return [
source_policy["table"]
for source_policy in policies
if isinstance(source_policy, dict)
and isinstance(source_policy.get("table"), str)
and source_policy["table"]
]
return []
def _wasm_invoice_paid_export(config: Any) -> str | None:
return config.events.on_invoice_paid
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
for export in extension.exports:
if export.name == export_name:
return export.visibility == "event"
return False
def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]:
return {
"checkingId": payment.checking_id,
"paymentHash": payment.payment_hash,
"walletId": payment.wallet_id,
"amount": payment.amount,
"fee": payment.fee,
"bolt11": payment.bolt11,
"memo": payment.memo,
"pending": payment.pending,
"status": payment.status,
"tag": payment.tag,
"extension": payment.extension,
"extra": payment.extra or {},
"payment": json.loads(payment.json()),
}

View file

@ -0,0 +1,94 @@
from __future__ import annotations
import asyncio
import re
from collections.abc import Mapping
from typing import Any
from wasmtime import component
from ..api.models import EmptyRequest
from ..api.registry import list_extension_api_methods
from ..api.runtime import ExtensionAPIHost
def add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
methods_by_interface: dict[str, list[Any]] = {}
for method in list_extension_api_methods():
methods_by_interface.setdefault(method.host_interface, []).append(method)
for host_interface, methods in methods_by_interface.items():
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
for method in methods:
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(
api_host,
method.method_id,
method.request_model is EmptyRequest,
event_loop,
),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
empty_request: bool,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
if empty_request:
def empty_host_import(_store: Any) -> Any:
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return empty_host_import
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
record = component.Record()
for key, item in value.items():
setattr(record, _camel_to_kebab(key), _to_component_value(item))
return record
def _to_component_value(value: Any) -> Any:
if isinstance(value, Mapping):
return _dict_to_component_record(value)
if isinstance(value, list):
return [_to_component_value(item) for item in value]
return value
def _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()

View file

@ -0,0 +1,279 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from typing import Any
from wasmtime import Store, WasiConfig, component
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from lnbits.settings import settings
from ..api.host import ExtensionHostAPI
from ..api.runtime import ExtensionAPIHost
from .component import _wasm_component, _wasm_engine
from .host import add_extension_host_imports
from .loader import WasmExtension
_WASM_EPOCH_DEADLINE_TICKS = 1_000_000_000
_WASM_UNLIMITED_FUEL = 2**63 - 1
async def invoke_wasm_extension_export(
ext_id: str,
export_name: str,
payload: Mapping[str, Any] | None = None,
*,
user: Any | None = None,
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
trigger_type: str = "unknown",
request_id: str | None = None,
method: str | None = None,
path: str | None = None,
event_type: str | None = None,
wallet_id: str | None = None,
payment_hash: str | None = None,
checking_id: str | None = None,
request_bytes: int | None = None,
context_data: dict | None = None,
) -> dict[str, Any]:
from lnbits.core.services.extensions import (
finish_wasm_invocation,
get_wasm_invocation_stop_reason,
resolve_wasm_runtime_limits,
start_wasm_invocation,
stop_wasm_invocation,
wasm_invocation_stop_requested,
)
extension = _get_registered_extension(ext_id)
installed_extension = await _active_installed_extension(extension)
permissions = installed_extension.permissions
limits = resolve_wasm_runtime_limits(installed_extension)
payload = payload or {}
payload_size = _json_size(payload)
effective_request_bytes = (
request_bytes if request_bytes is not None else payload_size
)
_check_wasm_request_size(effective_request_bytes, limits)
invocation = await start_wasm_invocation(
extension_id=extension.id,
export_name=export_name,
trigger_type=trigger_type,
user_id=_user_id(user) or owner_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=effective_request_bytes,
context={"host_context": context, **(context_data or {})},
runtime_limits=limits,
)
api = ExtensionHostAPI(
extension.id,
permissions,
user_id=_user_id(user),
access_token=access_token,
context=context,
owner_id=owner_id,
invocation_id=invocation.id,
runtime_limits=limits,
)
event_loop = asyncio.get_running_loop()
thread_task = asyncio.create_task(
asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload,
api,
event_loop,
invocation.id,
limits,
)
)
max_execution_ms = limits["wasm_runtime_max_execution_ms"]
timed_out = False
finished = False
try:
try:
if max_execution_ms > 0:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=max_execution_ms / 1000,
)
else:
result = await thread_task
except asyncio.TimeoutError as exc:
timed_out = True
stop_reason = "WASM execution time limit exceeded."
await stop_wasm_invocation(invocation.id, reason=stop_reason)
try:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=2,
)
except asyncio.TimeoutError:
await finish_wasm_invocation(
invocation.id,
status="timeout",
error_type="TimeoutError",
error_message=stop_reason,
stop_reason=stop_reason,
)
finished = True
raise TimeoutError(stop_reason) from exc
status = (
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "completed"
)
)
await finish_wasm_invocation(
invocation.id,
status=status,
response_bytes=_json_size(result),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
finished = True
return result
except Exception as exc:
if not finished:
await finish_wasm_invocation(
invocation.id,
status=(
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "failed"
)
),
error_type=exc.__class__.__name__,
error_message=str(exc),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
raise
def _invoke_wasm_extension_export_sync(
extension: WasmExtension,
export_name: str,
payload: Mapping[str, Any],
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
invocation_id: str,
limits: dict[str, int],
) -> dict[str, Any]:
from lnbits.core.services.extensions import attach_wasm_invocation_runtime
engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"])
store = Store(engine)
_set_store_limits(store, limits)
_set_store_fuel(store, limits)
store.set_epoch_deadline(_WASM_EPOCH_DEADLINE_TICKS)
attach_wasm_invocation_runtime(invocation_id, engine=engine, store=store)
store.set_wasi(WasiConfig())
linker = component.Linker(engine)
linker.add_wasip2()
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
wasm_component = _wasm_component(extension, limits)
instance = linker.instantiate(store, wasm_component)
function = instance.get_func(store, export_name)
if not function:
raise KeyError(
f"WASM extension '{extension.id}' has no export '{export_name}'."
)
result = function(store, json.dumps(payload))
function.post_return(store)
return _parse_wasm_export_result(result, limits)
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
if not isinstance(value, str):
return {"ok": True, "data": value}
max_response_bytes = limits["wasm_runtime_max_response_bytes"]
if max_response_bytes > 0:
response_size = len(value.encode())
if response_size > max_response_bytes:
raise ValueError(
f"WASM extension response is too large: {response_size} bytes."
)
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
return {"ok": True, "data": parsed}
def _get_registered_extension(ext_id: str) -> WasmExtension:
extension = core_app_extra.wasm_extension_registry.get(ext_id)
if extension:
return extension
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
async def _active_installed_extension(extension: WasmExtension) -> Any:
installed_extension = await get_installed_extension(extension.id)
if (
not installed_extension
or settings.lnbits_extensions_deactivate_all
or not installed_extension.active
):
raise PermissionError(f"WASM extension '{extension.id}' is deactivated.")
return installed_extension
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _set_store_limits(store: Any, limits: dict[str, int]) -> None:
store.set_limits(
memory_size=_wasm_limit(limits["wasm_runtime_max_memory_bytes"]),
table_elements=_wasm_limit(limits["wasm_runtime_max_table_elements"]),
instances=_wasm_limit(limits["wasm_runtime_max_instances"]),
tables=_wasm_limit(limits["wasm_runtime_max_tables"]),
memories=_wasm_limit(limits["wasm_runtime_max_memories"]),
)
def _set_store_fuel(store: Any, limits: dict[str, int]) -> None:
store.set_fuel(
limits["wasm_runtime_max_fuel"]
if limits["wasm_runtime_max_fuel"] > 0
else _WASM_UNLIMITED_FUEL
)
def _check_wasm_request_size(request_bytes: int, limits: dict[str, int]) -> None:
max_request_bytes = limits["wasm_runtime_max_request_bytes"]
if max_request_bytes > 0 and request_bytes > max_request_bytes:
raise ValueError(f"WASM extension request is too large: {request_bytes} bytes.")
def _wasm_limit(value: int) -> int:
return value if value > 0 else -1
def _json_size(value: Any) -> int:
return len(json.dumps(value, default=str).encode())

View file

@ -0,0 +1,107 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from lnbits.core.wasm_ext.wasm.config import (
WasmExtensionConfig,
WasmExtensionExport,
parse_wasm_extension_config,
)
from lnbits.settings import settings
@dataclass(frozen=True)
class WasmExtension:
id: str
name: str
version: str
root_path: Path
module_path: Path
wit_path: Path | None
world: str
exports: list[WasmExtensionExport]
config: WasmExtensionConfig
def is_wasm_extension_id(ext_id: str) -> bool:
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
config = _load_json(ext_dir / "config.json")
return bool(config and config.get("extension_type") == "wasm")
def is_wasm_extension_dir(ext_dir: Path) -> bool:
config = _load_json(ext_dir / "config.json")
return bool(config and config.get("extension_type") == "wasm")
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
config = _load_json(ext_dir / "config.json")
if not config or config.get("extension_type") != "wasm":
return None
return parse_wasm_extension_config(ext_id, config)
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
raw_config = _load_json(ext_dir / "config.json")
if not raw_config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
if raw_config.get("extension_type") != "wasm":
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
config = parse_wasm_extension_config(ext_id, raw_config)
module_path = _extension_path(ext_dir, config.wasm.module)
wit_path = _optional_extension_path(ext_dir, config.wasm.wit)
_check_wasm_module(module_path)
if wit_path and not wit_path.is_file():
raise FileNotFoundError(f"WIT file not found: {wit_path}")
return WasmExtension(
id=config.id,
name=config.name,
version=config.version,
root_path=ext_dir,
module_path=module_path,
wit_path=wit_path,
world=config.wasm.world,
exports=config.wasm.exports,
config=config,
)
def _load_json(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
with path.open("r", encoding="utf-8") as config_file:
value = json.load(config_file)
if not isinstance(value, dict):
raise ValueError(f"Expected JSON object in '{path}'.")
return value
def _extension_path(ext_dir: Path, value: Any) -> Path:
if not isinstance(value, str) or not value:
raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.")
path = (ext_dir / value).resolve()
if ext_dir.resolve() not in path.parents:
raise ValueError(f"Extension path escapes extension root: {value}")
return path
def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
if value is None:
return None
return _extension_path(ext_dir, value)
def _check_wasm_module(path: Path) -> None:
if not path.is_file():
raise FileNotFoundError(f"WASM module not found: {path}")
with path.open("rb") as wasm_file:
magic = wasm_file.read(4)
if magic != b"\0asm":
raise ValueError(f"Invalid WASM module: {path}")

View file

@ -497,7 +497,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values[f"{field}__{index}"] = validated
values[f"{field}__{i}_{index}"] = validated
else:
raise ValueError("Unknown filter field")
@ -510,11 +510,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else:
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
placeholder = f":{key}"
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(placeholder)
else:
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
@ -725,20 +726,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
for v in _items
]
continue
if issubclass(type_, bool):
if _is_subclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if _is_subclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
if _is_subclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
@ -763,6 +764,13 @@ def _safe_load_json(value: str) -> dict:
return {}
def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool:
try:
return issubclass(type_, class_or_tuple)
except TypeError:
return False
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (

View file

@ -448,7 +448,7 @@ async def _check_user_access(r: Request, user_id: str, conn: Connection | None =
async def _check_user_extension_access(
user_id: str, path: str, conn: Connection | None = None
):
ext_id = path_segments(path)[0]
ext_id = _extension_id_from_request_path(path)
status = await check_user_extension_access(user_id, ext_id, conn=conn)
if not status.success:
raise HTTPException(
@ -457,6 +457,15 @@ async def _check_user_extension_access(
)
def _extension_id_from_request_path(path: str) -> str:
segments = path_segments(path)
if len(segments) >= 2 and segments[0] == "ext":
return segments[1]
if len(segments) >= 4 and segments[:3] == ["api", "v1", "ext"]:
return segments[3]
return segments[0]
async def _get_account_from_token(
access_token: str, path: str, method: str, conn: Connection | None = None
) -> Account | None:

View file

@ -13,6 +13,7 @@ from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
from random_username.generate import generate_username # type: ignore[import-untyped]
from starlette.templating import Jinja2Templates
from lnbits.settings import settings
@ -22,6 +23,10 @@ from lnbits.utils.exchange_rates import currencies
from .db import FilterModel
def generate_ln_address() -> str:
return generate_username(1)[0].lower()
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (
@ -310,12 +315,7 @@ def get_api_routes(routes: list) -> dict[str, str]:
def path_segments(path: str) -> list[str]:
path = path.strip("/")
segments = path.split("/")
if len(segments) < 2:
return segments
if segments[0] == "upgrades":
return segments[2:]
return segments[0:]
return path.split("/")
def normalize_path(path: str | None) -> str:
@ -372,3 +372,13 @@ def sha256s(value: str) -> str:
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def daystart_timestamp(dt: datetime | None = None) -> int:
"""
Returns the timestamp of the start of the day for the given
datetime (or now in UTC if not provided).
"""
dt = dt or datetime.now(timezone.utc)
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start.timestamp())

View file

@ -51,14 +51,6 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
return
# re-route all trafic if the extension has been upgraded
if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
await self.app(scope, receive, send)
def _response_by_accepted_type(
@ -112,7 +104,7 @@ class ExtensionsRedirectMiddleware:
req_headers = scope["headers"] if "headers" in scope else []
redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect:
if redirect and not redirect.is_duplicate_well_known():
scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send)

View file

@ -27,6 +27,8 @@ from lnbits.settings import set_cli_settings, settings
@click.option(
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
)
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
def main(
port: int,
host: str,
@ -34,6 +36,8 @@ def main(
ssl_keyfile: str,
ssl_certfile: str,
reload: bool,
ws_max_queue: int,
ws_ping_timeout: float,
):
"""Launched with `uv run lnbits` at root level"""
@ -45,6 +49,7 @@ def main(
Path(settings.lnbits_extensions_path, "extensions").mkdir(
parents=True, exist_ok=True
)
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
@ -58,6 +63,8 @@ def main(
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
reload=reload or False,
ws_ping_timeout=ws_ping_timeout,
ws_max_queue=ws_max_queue,
)
server = uvicorn.Server(config=config)

View file

@ -11,12 +11,16 @@ from enum import Enum
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from typing import Any, Literal
from uuid import uuid4
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
DEFAULT_WASM_MANIFESTS = [
"https://raw.githubusercontent.com/lnbits/lnbits-extensions-wasm/refs/heads/main/extensions.json"
]
def list_parse_fallback(v: str):
v = v.replace(" ", "")
@ -41,6 +45,16 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_ln_address_mode: Literal[
"core_first", "extension_first", "extension_only"
] = Field(default="extension_first")
lnbits_allow_custom_wallet_lightning_addresses: bool = Field(default=False)
lnbits_charge_wallet_lightning_addresses: bool = Field(default=False)
lnbits_wallet_lightning_address_price_sats: int = Field(default=1000, ge=0)
lnbits_wallet_lightning_address_blacklist: list[str] = Field(
default=["admin", "info", "support", "help", "security"]
)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
@ -54,12 +68,17 @@ class UsersSettings(LNbitsSettings):
def new_accounts_allowed(self) -> bool:
return self.lnbits_allow_new_accounts and len(self.lnbits_allowed_users) == 0
@property
def ln_address_creation_allowed(self) -> bool:
return self.lnbits_ln_address_mode != "extension_only"
class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_wasm_invocation_retention_days: int = Field(default=7, ge=0)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
@ -72,6 +91,7 @@ class ExtensionsSettings(LNbitsSettings):
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
]
)
lnbits_wasm_extensions_manifests: list[str] = Field(default=DEFAULT_WASM_MANIFESTS)
lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
)
@ -81,6 +101,33 @@ class ExtensionsSettings(LNbitsSettings):
return Path(settings.lnbits_data_folder, "extensions_builder")
class WasmRuntimeLimits(LNbitsSettings):
# 0 disables the limit. Installed WASM extensions may override these defaults.
wasm_runtime_max_memory_bytes: int = Field(default=64 * 1024 * 1024, ge=0)
wasm_runtime_max_execution_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_fuel: int = Field(default=100_000_000, ge=0)
wasm_runtime_max_response_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_request_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_wasm_stack_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_table_elements: int = Field(default=10_000, ge=0)
wasm_runtime_max_instances: int = Field(default=8, ge=0)
wasm_runtime_max_tables: int = Field(default=10, ge=0)
wasm_runtime_max_memories: int = Field(default=1, ge=0)
wasm_runtime_max_concurrent_invocations: int = Field(default=16, ge=0)
wasm_runtime_max_concurrent_invocations_per_extension: int = Field(default=4, ge=0)
wasm_runtime_max_concurrent_invocations_per_user: int = Field(default=4, ge=0)
wasm_runtime_max_host_calls: int = Field(default=1_000, ge=0)
wasm_runtime_max_http_calls: int = Field(default=20, ge=0)
wasm_runtime_max_storage_calls: int = Field(default=100, ge=0)
wasm_runtime_max_wallet_calls: int = Field(default=20, ge=0)
wasm_runtime_http_timeout_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_http_response_bytes: int = Field(default=1024 * 1024, ge=0)
class ExtensionsInstallSettings(LNbitsSettings):
lnbits_extensions_default_install: list[str] = Field(default=[])
# required due to GitHUb rate-limit
@ -93,6 +140,9 @@ class RedirectPath(BaseModel):
redirect_to_path: str
header_filters: dict = {}
def is_duplicate_well_known(self) -> bool:
return self.from_path in ["/.well-known/lnurlp"]
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
@ -166,8 +216,6 @@ class ExchangeRateProvider(BaseModel):
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=set())
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
@ -190,18 +238,10 @@ class InstalledExtensionsSettings(LNbitsSettings):
def activate_extension_paths(
self,
ext_id: str,
upgrade_hash: str | None = None,
ext_redirects: list[dict] | None = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects)
@ -211,9 +251,6 @@ class InstalledExtensionsSettings(LNbitsSettings):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def extension_upgrade_hash(self, ext_id: str) -> str:
return settings.lnbits_upgraded_extensions.get(ext_id, "")
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
@ -362,6 +399,8 @@ class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@ -494,6 +533,11 @@ class NotificationsSettings(LNbitsSettings):
and self.lnbits_telegram_notifications_access_token is not None
)
def is_email_notifications_configured(self) -> bool:
return self.lnbits_email_notifications_enabled and bool(
self.lnbits_email_notifications_email
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@ -562,6 +606,7 @@ class LndGrpcFundingSource(LNbitsSettings):
lnd_grpc_invoice_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon_encrypted: str | None = Field(default=None)
lnd_grpc_allow_self_payment: bool = Field(default=False)
class LnPayFundingSource(LNbitsSettings):
@ -575,6 +620,10 @@ class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: str | None = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: str | None = Field(default="wss://ws.blink.sv/graphql")
blink_token: str | None = Field(default=None)
# If probing fails or is unsupported by the destination (e.g. fedimints),
# send the payment anyway. Blink reserves its max fee and reconciles any
# excess separately. If disabled, payments that cannot be probed will fail.
blink_send_without_probe: bool = Field(default=True)
class ZBDFundingSource(LNbitsSettings):
@ -587,6 +636,7 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings):
@ -606,11 +656,17 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None)
class BarkFundingSource(LNbitsSettings):
bark_api_endpoint: str | None = Field(default="http://localhost:3000")
bark_api_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_network: str = Field(default="MAINNET")
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@ -648,6 +704,7 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings):
@ -750,6 +807,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
BarkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
@ -840,6 +898,16 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False)
class BlockExplorerSettings(LNbitsSettings):
lnbits_blockexplorer_enabled: bool = Field(default=False)
lnbits_blockexplorer_public_api: bool = Field(default=False)
lnbits_blockexplorer_electrum_url: str = Field(
default="ssl://electrum.blockstream.info:50002"
)
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
lnbits_blockexplorer_network: str = Field(default="main")
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105
@ -1001,6 +1069,7 @@ class AuditSettings(LNbitsSettings):
class EditableSettings(
UsersSettings,
ExtensionsSettings,
WasmRuntimeLimits,
ThemesSettings,
OpsSettings,
AssetSettings,
@ -1013,6 +1082,7 @@ class EditableSettings(
LightningSettings,
WebPushSettings,
NodeUISettings,
BlockExplorerSettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
@ -1024,8 +1094,11 @@ class EditableSettings(
@validator(
"lnbits_admin_users",
"lnbits_allowed_users",
"lnbits_wallet_lightning_address_blacklist",
"lnbits_theme_options",
"lnbits_admin_extensions",
"lnbits_extensions_manifests",
"lnbits_wasm_extensions_manifests",
pre=True,
)
@classmethod
@ -1072,11 +1145,12 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
task_heart_beat_verbose: bool = Field(default=False)
task_heart_beat_interval: int = Field(default=30)
@property
def has_default_extension_path(self) -> bool:
@ -1107,12 +1181,39 @@ class EnvSettings(LNbitsSettings):
class PersistenceSettings(LNbitsSettings):
lnbits_data_folder: str = Field(default="./data")
lnbits_database_url: str | None = Field(default=None)
lnbits_wasm_extensions_path: str = Field(default="")
@validator("lnbits_wasm_extensions_path", pre=True, always=True)
@classmethod
def validate_wasm_extensions_path(cls, value, values) -> str:
if value:
return str(value)
return str(Path(values.get("lnbits_data_folder", "./data"), "wasm_extensions"))
@property
def wasm_extensions_dir(self) -> Path:
wasm_dir = Path(self.lnbits_wasm_extensions_path)
importable_dirs = (
Path(getattr(self, "lnbits_extensions_path", "lnbits"), "extensions"),
Path(self.lnbits_data_folder, "upgrades"),
)
resolved_wasm_dir = wasm_dir.resolve()
if any(
resolved_dir == resolved_wasm_dir
or resolved_dir in resolved_wasm_dir.parents
for resolved_dir in (path.resolve() for path in importable_dirs)
):
raise ValueError(
"WASM extensions path must be outside importable extension directories."
)
return wasm_dir
class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"AlbyWallet",
"BarkWallet",
"BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
@ -1262,6 +1363,7 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
@ -1289,10 +1391,23 @@ class PublicSettings(BaseModel):
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
email_configured: bool = Field(alias="emailConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
enable_wallet_lightning_addresses: bool = Field(
alias="enableWalletLightningAddresses"
)
allow_custom_wallet_lightning_addresses: bool = Field(
alias="allowCustomWalletLightningAddresses"
)
charge_wallet_lightning_addresses: bool = Field(
alias="chargeWalletLightningAddresses"
)
wallet_lightning_address_price_sats: int = Field(
alias="walletLightningAddressPriceSats"
)
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
@ -1329,6 +1444,7 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
@ -1353,10 +1469,21 @@ class PublicSettings(BaseModel):
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
emailConfigured=settings.is_email_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
enableWalletLightningAddresses=settings.ln_address_creation_allowed,
allowCustomWalletLightningAddresses=(
settings.lnbits_allow_custom_wallet_lightning_addresses
),
chargeWalletLightningAddresses=(
settings.lnbits_charge_wallet_lightning_addresses
),
walletLightningAddressPriceSats=(
settings.lnbits_wallet_lightning_address_price_sats
),
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -99,6 +99,10 @@ window.localisation.br = {
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
api_docs: 'Documentação da API',
api_keys_api_docs: 'URL do Node, chaves da API e documentação da API',
api_keys_warning:
'Essas chaves devem ser mantidas em segurança; compartilhá-las pode resultar na perda de fundos.',
admin_key_warning:
'Sua chave de administrador concede acesso total à sua carteira, incluindo a capacidade de enviar pagamentos. Nunca a compartilhe, a menos que confie plenamente no destinatário.',
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
paste: 'Colar',
@ -824,5 +828,48 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor',
sort: 'Ordenar',
sort_by: 'Ordenar por'
sort_by: 'Ordenar por',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem transações demais)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}

View file

@ -68,6 +68,9 @@ window.localisation.cn = {
view_swagger_docs: '查看 LNbits Swagger API 文档',
api_docs: 'API文档',
api_keys_api_docs: '节点URL、API密钥和API文档',
api_keys_warning: '请妥善保管这些密钥,分享它们可能导致资金损失。',
admin_key_warning:
'您的管理员密钥可完全访问您的钱包,包括发送付款的权限。除非您完全信任接收者,否则切勿分享。',
lnbits_version: 'LNbits版本',
runs_on: '可运行在',
paste: '粘贴',
@ -419,5 +422,44 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码',
request_details: '请求详情',
http_request_details: 'HTTP请求详细信息'
http_request_details: 'HTTP请求详细信息',
block_explorer: '区块浏览器',
enable_block_explorer: '启用区块浏览器',
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
blockexplorer_public_api: '公开 API 访问',
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
electrum_server_url: 'Electrum 服务器 URL',
electrum_server_url_hint:
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
blockexplorer_search_label: '按 TXID 或地址搜索',
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
recent_blocks: '最新区块',
chain_tip: '链尖',
block_height: '区块高度',
block_fee: '区块手续费',
fee_estimates: '手续费估算',
confirmed_balance: '已确认余额',
unconfirmed_balance: '未确认余额',
transaction_history: '交易历史',
coinbase: 'Coinbase',
inputs: '输入',
outputs: '输出',
confirmations: '确认数',
confirmed: '已确认',
unconfirmed: '未确认',
history_unavailable: '交易历史不可用(地址交易过多)',
address: '地址',
block_number: '区块 #{height}',
block_diff: '难度 {value}',
block_hash: '哈希',
previous_block: '上一区块',
merkle_root: 'Merkle 根',
version: '版本',
bits: 'Bits',
difficulty: '难度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '虚拟大小',
weight: '权重',
n_block_fee: '{n} 区块手续费'
}

View file

@ -72,6 +72,10 @@ window.localisation.cs = {
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
api_docs: 'API dokumentace',
api_keys_api_docs: 'Adresa uzlu, API klíče a API dokumentace',
api_keys_warning:
'Tyto klíče uchovávejte v bezpečí, jejich sdílení může vést ke ztrátě prostředků.',
admin_key_warning:
'Váš administrátorský klíč poskytuje plný přístup k peněžence včetně možnosti odesílat platby. Nikdy jej nesdílejte, pokud příjemci plně nedůvěřujete.',
lnbits_version: 'Verze LNbits',
runs_on: 'Běží na',
paste: 'Vložit',
@ -439,5 +443,48 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti',
http_request_details: 'Podrobnosti HTTP žádosti'
http_request_details: 'Podrobnosti HTTP žádosti',
block_explorer: 'Průzkumník bloků',
enable_block_explorer: 'Povolit průzkumník bloků',
block_explorer_desc:
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
blockexplorer_public_api: 'Veřejný přístup k API',
blockexplorer_public_api_desc:
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
electrum_server_url: 'URL Electrum serveru',
electrum_server_url_hint:
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
recent_blocks: 'Nedávné bloky',
chain_tip: 'Vrchol řetězu',
block_height: 'Výška bloku',
block_fee: 'poplatek bloku',
fee_estimates: 'Odhady poplatků',
confirmed_balance: 'Potvrzený zůstatek',
unconfirmed_balance: 'Nepotvrzený zůstatek',
transaction_history: 'Historie transakcí',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrzení',
confirmed: 'Potvrzeno',
unconfirmed: 'Nepotvrzeno',
history_unavailable:
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Předchozí blok',
merkle_root: 'Merkle kořen',
version: 'Verze',
bits: 'Bity',
difficulty: 'Obtížnost',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuální velikost',
weight: 'Váha',
n_block_fee: 'poplatek {n} bloků'
}

View file

@ -72,6 +72,10 @@ window.localisation.de = {
view_swagger_docs: 'LNbits Swagger API-Dokumentation',
api_docs: 'API-Dokumentation',
api_keys_api_docs: 'Knoten-URL, API-Schlüssel und API-Dokumentation',
api_keys_warning:
'Diese Schlüssel sollten sicher aufbewahrt werden; ihre Weitergabe kann zum Verlust von Guthaben führen.',
admin_key_warning:
'Dein Admin-Schlüssel gewährt vollen Zugriff auf deine Wallet, einschließlich der Möglichkeit, Zahlungen zu senden. Teile ihn niemals, es sei denn, du vertraust dem Empfänger vollständig.',
lnbits_version: 'LNbits-Version',
runs_on: 'Läuft auf',
paste: 'Einfügen',
@ -452,5 +456,48 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails',
http_request_details: 'HTTP-Anfragedetails'
http_request_details: 'HTTP-Anfragedetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer aktivieren',
block_explorer_desc:
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
blockexplorer_public_api: 'Öffentlicher API-Zugang',
blockexplorer_public_api_desc:
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
electrum_server_url: 'Electrum-Server-URL',
electrum_server_url_hint:
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
blockexplorer_search_hint:
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
recent_blocks: 'Aktuelle Blöcke',
chain_tip: 'Kettenspitze',
block_height: 'Blockhöhe',
block_fee: 'Blockgebühr',
fee_estimates: 'Gebührenschätzungen',
confirmed_balance: 'Bestätigtes Guthaben',
unconfirmed_balance: 'Unbestätigtes Guthaben',
transaction_history: 'Transaktionsverlauf',
coinbase: 'Coinbase',
inputs: 'Eingaben',
outputs: 'Ausgaben',
confirmations: 'Bestätigungen',
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
history_unavailable:
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
address: 'Adresse',
block_number: 'Block #{height}',
block_diff: 'Schw. {value}',
block_hash: 'Hash',
previous_block: 'Vorheriger Block',
merkle_root: 'Merkle-Wurzel',
version: 'Version',
bits: 'Bits',
difficulty: 'Schwierigkeit',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuelle Größe',
weight: 'Gewicht',
n_block_fee: '{n}-Block-Gebühr'
}

View file

@ -44,7 +44,8 @@ window.localisation.en = {
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
name_your_wallet: 'Name your {name} wallet',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
paste_invoice_label:
'Paste an invoice, payment request, Lightning Address or LNURL*',
lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
export_to_phone: 'Export to Phone with QR Code',
@ -64,6 +65,7 @@ window.localisation.en = {
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
add_field: 'Add field',
reject_wallet: 'Reject wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
@ -98,6 +100,10 @@ window.localisation.en = {
view_swagger_docs: 'View LNbits Swagger API docs',
api_docs: 'API docs',
api_keys_api_docs: 'Node URL, API keys and API docs',
api_keys_warning:
'These keys should be kept safe, sharing them could risk losing funds.',
admin_key_warning:
'Your admin key grants full access to your wallet, including the ability to send payments. Never share it unless you fully trust the recipient.',
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
paste: 'Paste',
@ -116,6 +122,7 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@ -161,6 +168,10 @@ window.localisation.en = {
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
ext_sources_label:
'Source URL (only use the official LNbits extension source, and sources you can trust)',
wasm_sources_hint:
'Repositories from where WASM extensions can be downloaded',
wasm_sources_label:
'WASM source URL (only use WASM extension sources you can trust)',
warning: 'Warning',
repository: 'Repository',
confirm_continue: 'Are you sure you want to continue?',
@ -482,6 +493,8 @@ window.localisation.en = {
access_control_list_admin_warning:
'This is an admin account. The generated tokens will have admin privileges.',
new_api_acl: 'New Access Control List',
acl_token_active: 'Active',
acl_token_expired: 'Expired',
api_token_id: 'Token Id',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
@ -519,6 +532,64 @@ window.localisation.en = {
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
extension_permissions_title: 'Grant extension permissions',
extension_permissions_tab: 'Extension Permissions',
user_permissions_tab: 'My Grants',
extension_permissions_none: 'This extension has no install-time permissions.',
user_permissions_none:
'You have not granted any permissions for this extension.',
user_permissions_max_amount: 'Max payment amount',
user_permissions_destination_policy: 'Allowed destinations',
user_permissions_no_editable_settings: 'This grant has no editable settings.',
extension_permissions_grant_install: 'Grant and install',
extension_permissions_high_risk_warning:
'This extension requests permissions that can move funds.',
extension_permission_risk_low: 'Low risk',
extension_permission_risk_medium: 'Medium risk',
extension_permission_risk_high: 'High risk',
extension_permission_warning_wallet_pay_invoice:
'Can spend funds from wallets available to your account.',
extension_permission_warning_wallet_pay_invoice_background:
'Can spend funds later from approved wallets without an active click.',
extension_permission_warning_wallet_payments_watch:
'Can read payment metadata for approved wallets.',
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_append_public:
'Append public extension storage',
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_append_public_max_rows_per_source:
'Max rows per source',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
extension_permission_extension_api_request_extensions: 'Allowed extensions',
extension_permission_access_read: 'Read',
extension_permission_access_write: 'Write',
extension_permission_http_request: 'Connect to external websites',
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_websocket: 'Use extension websockets',
extension_permission_websocket_publish_limits: 'Publish limits',
extension_permission_websocket_publish_max_messages_per_second:
'Max messages per second',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_balance_read: 'View wallet balances',
extension_permission_wallet_list: 'List wallets',
extension_permission_wallet_pay_invoice: 'Pay invoices',
extension_permission_wallet_pay_invoice_background:
'Make background payments',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
@ -687,7 +758,10 @@ window.localisation.en = {
service_fees: 'Service Fees',
service_fee: 'Service Fee',
service_fee_label: 'Service Fee Charged Per Transaction',
service_fee_hint: 'Fee charged per transaction (%)',
service_fee_max: 'Maximum Service Fee (sats)',
service_fee_max_label: 'Maximum Service Fee Limit',
service_fee_max_hint: 'Maximum service fee to charge in (sats)',
fee_wallet_label: 'Service Fee Wallet ID',
fee_wallet_hint: 'The ID of the wallet to which to send service funds',
disable_fee: 'Disable Service Fees for Internal Payments',
@ -841,5 +915,80 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated',
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by'
sort_by: 'Sort by',
lightning_address: 'Lightning Address',
lightning_addresses: 'Lightning Addresses',
lightning_address_price: 'Lightning Address price',
enable_lightning_address: 'Enable Lightning Addresses',
ln_address_mode: 'Lightning Address Resolution Mode',
ln_address_core_first: 'Resolve from LNbits Core first',
ln_address_extension_first: 'Resolve from Pay Links extension first',
ln_address_extension_only: 'Resolve from Pay Links extension only',
ln_address_mode_hint:
'Choose how LNbits should resolve Lightning Addresses. Using both LNbits Core and the Pay Links extension will have a small impact on performance.',
enable_lightning_address_for_all_wallets:
'Enable Lightning Addresses for all LNbits wallets',
allow_users_specify_lightning_addresses:
'Allow users to specify Lightning Addresses',
allow_wallet_owners_set_custom_lightning_addresses:
'Allow wallet owners to set custom Lightning Addresses',
charge_for_lightning_addresses: 'Charge for Lightning Addresses',
charge_users_set_change_lightning_address:
'Charge users when they set or change a Lightning Address.',
service_fee_wallet_id_must_be_set:
'Service Fee Wallet ID must be set in the Service Fees section below for this to work.',
lightning_address_blacklist: 'Lightning Address blacklist',
lightning_address_blacklist_instructions:
'Newline separated reserved words. Users cannot choose a Lightning Address that matches any of these words.',
set_lightning_address: 'Set Lightning Address',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Enable Block Explorer',
block_explorer_desc:
'Allow users to explore Bitcoin transactions and addresses via Electrum.',
blockexplorer_public_api: 'Public API Access',
blockexplorer_public_api_desc:
'Allow unauthenticated access to the block explorer API endpoints.',
electrum_compatible_server: 'Electrum compatible server',
electrum_server_url: 'Electrum Server URL',
electrum_server_url_hint:
'Choose a public Electrum server or enter your own.',
electrum_server_url_custom: 'Custom Electrum Server URL',
view_public_electrum_servers: 'View public Electrum servers',
blockexplorer_network: 'Bitcoin Network',
blockexplorer_network_hint:
'The network the Electrum server is connected to, used to render addresses correctly.',
blockexplorer_search_label: 'Search by TXID or Address',
blockexplorer_search_hint:
'64-char hex = transaction · anything else = Bitcoin address',
recent_blocks: 'Recent Blocks',
chain_tip: 'Chain Tip',
block_height: 'Block Height',
block_fee: 'block fee',
fee_estimates: 'Fee Estimates',
confirmed_balance: 'Confirmed Balance',
unconfirmed_balance: 'Unconfirmed Balance',
transaction_history: 'Transaction History',
coinbase: 'Coinbase',
inputs: 'Inputs',
outputs: 'Outputs',
confirmations: 'Confirmations',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found',
history_unavailable:
'Transaction history unavailable (address has too many transactions)',
address: 'Address',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block fee'
}

View file

@ -72,6 +72,10 @@ window.localisation.es = {
view_swagger_docs: 'Ver documentación de API de LNbits Swagger',
api_docs: 'Documentación de API',
api_keys_api_docs: 'URL del nodo, claves de API y documentación de API',
api_keys_warning:
'Estas claves deben mantenerse seguras; compartirlas podría provocar la pérdida de fondos.',
admin_key_warning:
'Tu clave de administrador otorga acceso total a tu billetera, incluida la posibilidad de enviar pagos. Nunca la compartas a menos que confíes plenamente en el destinatario.',
lnbits_version: 'Versión de LNbits',
runs_on: 'Corre en',
paste: 'Pegar',
@ -453,5 +457,48 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud',
http_request_details: 'Detalles de la Solicitud HTTP'
http_request_details: 'Detalles de la Solicitud HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activar Block Explorer',
block_explorer_desc:
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
blockexplorer_public_api: 'Acceso a la API pública',
blockexplorer_public_api_desc:
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
electrum_server_url: 'URL del servidor Electrum',
electrum_server_url_hint:
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Buscar por TXID o dirección',
blockexplorer_search_hint:
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
recent_blocks: 'Bloques recientes',
chain_tip: 'Punta de cadena',
block_height: 'Altura de bloque',
block_fee: 'tarifa de bloque',
fee_estimates: 'Estimaciones de tarifa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo no confirmado',
transaction_history: 'Historial de transacciones',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Salidas',
confirmations: 'Confirmaciones',
confirmed: 'Confirmado',
unconfirmed: 'No confirmado',
history_unavailable:
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
address: 'Dirección',
block_number: 'Bloque #{height}',
block_diff: 'dif {value}',
block_hash: 'Hash',
previous_block: 'Bloque anterior',
merkle_root: 'Raíz de Merkle',
version: 'Versión',
bits: 'Bits',
difficulty: 'Dificultad',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamaño virtual',
weight: 'Peso',
n_block_fee: 'tarifa {n} bloques'
}

View file

@ -81,6 +81,10 @@ window.localisation.fi = {
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
api_docs: 'API-dokumentaatio',
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
api_keys_warning:
'Pidä nämä avaimet turvassa, sillä niiden jakaminen voi johtaa varojen menetykseen.',
admin_key_warning:
'Ylläpitäjäavaimesi antaa täyden pääsyn lompakkoosi, myös maksujen lähettämiseen. Älä koskaan jaa sitä, ellet täysin luota vastaanottajaan.',
lnbits_version: 'LNbits versio',
runs_on: 'Mukana menossa',
paste: 'Liitä',
@ -647,5 +651,48 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
'The user will be redirected to this URL after the payment is successful',
block_explorer: 'Lohkoselain',
enable_block_explorer: 'Ota lohkoselain käyttöön',
block_explorer_desc:
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
blockexplorer_public_api: 'Julkinen API-pääsy',
blockexplorer_public_api_desc:
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
electrum_server_url: 'Electrum-palvelimen URL',
electrum_server_url_hint:
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
blockexplorer_search_hint:
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
recent_blocks: 'Viimeisimmät lohkot',
chain_tip: 'Ketjun kärki',
block_height: 'Lohkokorkeus',
block_fee: 'lohkomaksu',
fee_estimates: 'Maksuarviot',
confirmed_balance: 'Vahvistettu saldo',
unconfirmed_balance: 'Vahvistamaton saldo',
transaction_history: 'Tapahtumahistoria',
coinbase: 'Coinbase',
inputs: 'Syötteet',
outputs: 'Tulosteet',
confirmations: 'Vahvistukset',
confirmed: 'Vahvistettu',
unconfirmed: 'Vahvistamaton',
history_unavailable:
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
address: 'Osoite',
block_number: 'Lohko #{height}',
block_diff: 'vaikeus {value}',
block_hash: 'Hash',
previous_block: 'Edellinen lohko',
merkle_root: 'Merkle-juuri',
version: 'Versio',
bits: 'Bitit',
difficulty: 'Vaikeus',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuaalikoko',
weight: 'Paino',
n_block_fee: '{n} lohkon maksu'
}

901
lnbits/static/i18n/fo.js Normal file
View file

@ -0,0 +1,901 @@
window.localisation.fo = {
confirm: 'Ja',
server: 'Ambætari',
theme: 'Snið',
site_customisation: 'Vevsíðu tillagingar',
funding: 'Fígging',
users: 'Brúkarar',
audit: 'Slóðfesti',
api_watch: 'API nýtsluyvirlit',
apps: 'Appir',
channels: 'Rásir',
transactions: 'Flytingar',
dashboard: 'Yvirlitssýni',
node: 'Knútur',
export_users: 'Tak brúkarar út',
no_users: 'Eingin brúkari finst',
total_capacity: 'Samlað rásarupphædd',
avg_channel_size: 'Miðal rásarupphædd',
biggest_channel_size: 'Størsta rásarupphædd',
smallest_channel_size: 'Minsta rásarupphædd',
number_of_channels: 'Tal av rásum',
active_channels: 'Virknar rásir',
connect_peer: 'Sambind við javninga',
connect: 'Sambind',
reconnect: 'Sambind aftur',
open_channel: 'Ger rás',
open: 'Lat upp',
clear: 'Tómstilla',
close_channel: 'Loka rás',
close: 'Loka',
restart: 'Endurbyrja ambætara',
image_library: 'Myndasavn',
save: 'Goym',
save_tooltip: 'Goym broytingarnar',
must_save: 'Tú hevur framt broytingar ið enn ikki eru goymdar.',
credit_debit: 'Góðskriva / Skuldskriva',
credit_hint:
'Trýst á Enter til tess at góðskriva/skuldskriva mappu (negativ viðri eru loyvd)',
credit_label: '{denomination} at góðskriva/skuldskriva',
credit_ok:
'Tað eydnaðist at góðskriva/skuldskriva tykisligan pening ({amount} sats). Gjøld eru treytaði av veruligum peningi á fíggingarkelduni.',
restart_tooltip: 'Endurbyrja ambætaran fyri at broytingarnar fáa virknað',
add_funds_tooltip: 'Set pening á eina mappu.',
reset_defaults: 'Endurset til forsettar stillingar',
reset_defaults_tooltip:
'Strika allar stillingar og endurset tær til forsettar.',
download_backup: 'Tak niður dátugrunstrygdaravrit',
name_your_wallet: 'Navngev tína {name} mappu',
paste_invoice_label: 'Innset ein faktura, gjaldsumbøn ella LNURL-kotu *',
lnbits_description:
'LNbits er kravlítil skipan, ið er løtt at innleggja og uppseta, og kann brúka einhvørja fíggingarkeldu á Lightning netinum. Tú kanst koyra LNbits til egna nýtslu og lættliga veita øðrum eina varðveitsluloysn. Tú fært gjørt eitt óavmarkað tal av mappum har hvør mappa hevur sínar egnu API-lyklar. Við møgulleikanum at býta pening gerst LNbits eitt hent amboð til peningaumsiting og eitt menningaramboð. Ískoytisforrit leggja virkisføri afturat LNbits so at tú kanst royna teg við eini røð av framkomnum tøknum á Lightning netinum. Vit hava gjørt tað so lætt sum møguligt, at menna ískoytisforrit, og sum ein fræls og gjøgnumskygd verkætlan eggja vit fólki, at menna og leggja fram teirra egnu ískoytisforrit.',
export_to_phone: 'Tak út til snildfon við QR-kotu',
export_to_phone_desc:
'QR-kotan inniheldur eina leinkju ið vísir til og gevur full rættindi til tína mappu. Skannar tú hana, t.d. við tíni snildfon, kanst tú lata upp mappu nýtaramótið har.',
access_wallet_on_mobile: 'Faratgongd',
stored_paylinks: 'Goymdar LNURL-gjaldleinkjur',
wallet: 'Mappa: ',
wallet_name: 'Mappunavn',
wallet_type: 'Slag av mappu',
shared_wallet: 'Deild mappa',
share_wallet: 'Deil mappu',
update_permissions: 'Broyt loyvir',
shared_wallet_id: 'Eyðmerki á deildari mappu',
shared_wallet_desc: 'Tú ert boðin atgongd til aðra mappu.',
wallets: 'Mappur',
exclude_wallets: 'Útiloka mappur',
add_wallet: 'Ger mappu',
reject_wallet: 'Vraka mappuatgongd',
add_new_wallet: 'Legg nýggja mappu afturat',
pin_wallet: 'Fest á mappulista',
delete_wallet: 'Strika mappu',
delete_wallet_desc:
'Mappan verður strikað og peningurin í henni fæst IKKI AFTUR.',
rename_wallet: 'Nýnevn mappu',
update_name: 'Nýnevn',
fiat_tracking: 'Fiat rekjan',
fiat_providers: 'Fiat meklarir',
fiat_warning_bitcoin:
'Ber teg undan at brúka orðið "bitcoin" tínum samskifti við fiat meklarar, tí teir hava lyndi at gerast fjálturstungnir um tað ið hevur við Bitcoin at gera!',
currency: 'Gjaldoyra',
update_currency: 'Broyt gjaldoyra',
press_to_claim: 'Trýst til tess at taka ímóti bitcoin',
claim_desc:
'Tú tykist hava krav á einari bitcoin upphædd men hevur ikki eina mappu enn. Trýst á knøttin niðanfyri fyri at taka ímóti henni. Ein nýggj mappa verður gjørd tær.',
donate: 'Veit fíggjarligan stuðul',
view_github: 'Lat upp LNbits verkætlanina á GitHub',
voidwallet_active: 'VoidWallet er virkin! Gjøld eru óvirkt',
voidwallet_active_user:
'Fíggingarkelda er ikki tøk. Vinaliga bið skipanarumsitaran um at fáa fíggingarkelduna í rættlag.',
voidwallet_active_admin:
'Fíggingarkelda er ikki tøk. Trýst á her fyri at uppseta.',
service_fee_badge: 'Tænastuavgjald: {amount} % fyri hvørja flyting',
service_fee_max_badge:
'Tænastuavgjald: {amount} % fyri hvørja flyting (í mesta lagi {max} {denom})',
service_fee_tooltip:
'Tænastuavgjald ið LNbits-ambætaraumsitarin krevur fyri hvørt útgjald',
toggle_darkmode: 'Myrkt snið',
payment_reactions: 'Gjaldsviðbrøgd',
view_swagger_docs: 'Lat upp LNbits Swagger API-skjalfestingina',
api_docs: 'API skjalfesting',
api_keys_api_docs: 'Knútaleinkja, API-lyklar og API-skjalfesting',
lnbits_version: 'LNbits útgáva',
runs_on: 'Koyrir á',
paste: 'Innset',
paste_from_clipboard: 'Innset frá setiborði',
paste_request: 'Innset umbøn',
create_invoice: 'Stovna gjaldsumbøn',
camera_tooltip: 'Brúka myntatólið at skanna eina gjaldsumbøn/QR-kotu',
export_csv: 'Tak út sum CSV',
export_csv_details: 'Tak út sum CSV, við smálutum',
chart_tooltip: 'Vís talvu',
pending: 'Ógoldin',
copy_invoice: 'Avrita gjaldsumbøn',
withdraw_from: 'Tak út úr',
cancel: 'Avlýs',
scan: 'Skanna',
read: 'Innles', // This is used both in terms of "reading/loading/parsing an invoice" and in terms of "ACL read rights". The word for those terms is not the same in faroese, but the former was chosen because it is the most visible in LNbits.
write: 'Skriva',
pay: 'Rinda',
sending: 'Sendur',
memo: 'Viðmerking',
date: 'Dagfesting',
path: 'Leið',
internal_memo: 'Egin viðmerking (valfríur)',
internal_memo_hint_receive:
'Viðmerkingin verður ikki víst rindaranum men verður knýtt at fakturanum, so tú kanst nýta hana sum tilvísing.',
internal_memo_hint_pay:
'Viðmerkingin verður ikki vist móttakaranum men verður knýtt at gjaldsumbønini, so tú kanst nýta hana sum tilvísing.',
payment_processing: 'Gjald verður avgreitt...',
payment_successful: 'Goldið!',
payment_pending: 'Gjald er ikki goldið enn...',
payment_check: 'Kanna greiðslu',
not_enough_funds: 'Ónøktandi salda!',
search_by_tag_memo_amount: 'Leita eftir spjaldri, viðmerking, upphædd',
search: 'Leita',
invoice_waiting: 'Gjaldsumbøn ið bíðar eftir at verða goldin',
payment_received: 'Inngjald',
payment_sent: 'Útgjald',
payment_failed: 'Gjald miseydnaðist',
receive: 'Móttak',
send: 'Send',
outgoing_payment_pending: 'Útgjald í bíðistøðu',
drain_funds: 'Tøm mappu',
drain_funds_desc:
'Hendan LNURL-úttøku QR-kotan kann brúkast at taka allan peningin úr mappuni. Ikki deila QR-kotuna við nakran. Hon ger nýtslu av balanceCheck og balanceNotify og kann tí brúkast fleiri ferðir at tøma mappuna.',
i_understand: 'Eg skilji',
copy_wallet_url: 'Avrita mappuleinkju',
disclaimer_dialog_title: 'Gev gætur!',
disclaimer_dialog:
'Tú *noyðist* at varðveita tínar innritanarupplýsingar til tess at fáa atgongd til mappuna aftur. Missir tú tær missir tú atgongd til mappuna og peningin.\n\nTú finnur tínar innritanarupplýsingar undir "Mín brúkari" > "Brúkara uppsetan".\n\nLNbits tekur ikki ábyrgd fyri mistari atgongd til pening.',
no_transactions: 'Enn eru ongar flytingar gjørdar',
manage: 'Umsit',
exchanges: 'Gjaldoyrakostnaðir',
extensions: 'Ískoytisforrit',
no_extensions: 'Einki ískoytisforrit er innlagt :(',
created: 'Stovnað',
created_at: 'Stovnað',
updated_at: 'Broytt',
search_extensions: 'Leita eftir ískoytisforritum',
search_wallets: 'Leita eftir mappum',
extension_sources: 'Ískoytisforritakeldur',
ext_sources_hint: 'Keldur hiðan ískoytisforrit verða tikin niður',
ext_sources_label:
'Kelduleinkja (brúka einans almennu LNbits-ískoytisforritakelduna og keldur tú hevur álit á)',
warning: 'Gev gætur',
repository: 'Kelda',
confirm_continue: 'Ynskir tú at halda áfram?',
manage_extension_details: 'Innlegg/Strika ískoytisforritið',
upload: 'Uppsend',
install: 'Innlegg',
uninstall: 'Strika',
drop_db: 'Strika dáturnar',
enable: 'Virkja',
enabled: 'Virktur',
disabled: 'Óvirkt(ur)',
pay_to_enable: 'Rinda til tess at virkja',
enable_extension_details: 'Virkja ískoytisforrit fyri verandi brúkara',
disable: 'Óvirkja',
delete: 'Strika',
installed: 'Innløgd',
activated: 'Virkt(ur)', //used for users and extensions which are different genders in faroese
deactivated: 'Óvirkt',
activate: 'Virkja',
deactivate: 'Óvirkja',
release_notes: 'Sleppingarskriv',
activate_extension_details:
'Virkja ella óvirkja ískoytisforritið fyri brúkarum',
featured: 'Víðagitin',
categories: 'Flokkar',
all: 'Øll',
only_admins_can_install: '(Einans umsitarar kunnu innleggja ískoytisforrit)',
only_admins_can_create_extensions:
'Einans umsitarar kunnu gera ískoytisforrit',
admin_only: 'Einans umsitarar',
make_user_admin: 'Játta umsitanarrættindi',
revoke_admin: 'Ógilda umsitanarrættindi',
new_version: 'Nýggj útgáva',
reviews_url: 'Ummælaleinkja',
reviews_url_label: 'Ummælaambætaraleinkja',
reviews_url_hint:
'Leinkja til PaidReviews/ummælir, íroknað stillingareymerki (t.d. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'Sí ummælir',
reviews_leave: 'Gev ummæli',
reviews_name: 'Títt navn',
reviews_comment: 'Títt ummæli',
reviews_rating: 'Meting',
reviews_submit: 'Send inn ummælið',
reviews_loading: 'Innlesur ummælir...',
reviews_refresh: 'Endurinnlesur ummælir',
reviews_error_load: 'Miseydnaðist at innlesa ummælir',
reviews_url_not_configured: 'Ummælaleinkja ikki ásett',
reviews_pay_invoice: 'Rinda gjaldsumbøn',
reviews_invoice_paid: 'Gjaldsumbøn goldin',
reviews_invoice_title:
'Rinda hesa gjaldsumbøn til tess at skráseta títt ummæli',
reviews_count: 'Ummælir',
no_reviews: 'Ongin ummælir enn',
extension_has_free_release: 'Hevur ókeypis útgávur',
extension_has_paid_release: 'Hevur útgávur til keyps',
extension_depends_on: 'Er treytað av:',
extension_rating_soon: 'Metingar koma skjótt',
extension_installed_version: 'Innløgd útgáva',
extension_uninstall_warning:
'Tú ert í ferð við at strika ískoytisforritið fyri allar brúkararnar.',
uninstall_confirm: 'Ja, strika',
extension_db_drop_info:
'Allar dátur tilhoyrandi ískoytisforritið verða strikaðar. Eftir hesa gerð vendst ikki aftur!',
extension_db_drop_warning:
'Tú ert í ferð við at strika allar dátur tilhoyrandi ískoytisforritið. Vinaliga skriva navnið á ískoytisforritinum til tess at halda á fram:',
extension_required_lnbits_version: 'Hendan útgávan tørvar LNbits útgávu',
min_version: 'Í minsta lagi',
max_version: 'Upp til (ikki íroknað)',
preimage: 'Frumvirði',
preimage_hint: 'Frumvirði til tess at avrokna varðveitslugjaldsumbøn',
hold_invoice: 'Varðveitslugjaldsumbøn',
hold_invoice_description:
'Hendan gjaldsumbønin er í varðveitslu og tørvur er á frumvirði til tess at gjalda hana.',
payment_hash: 'Gjalds-hash',
invoice_cancelled: 'Gjaldsumbøn ógilda',
invoice_settled: 'Gjaldsumbøn avroknað',
hold_invoice_payment_hash:
'Gjalds-hash fyri varðveitslugjaldsumbøn (valfríur)',
settle_invoice: 'Avrokna gjaldsumbøn',
cancel_invoice: 'Ógilda gjaldsumbøn',
fee: 'Avgjald',
amount: 'Upphædd',
amount_limits: 'Upphæddarmørk',
amount_sats: 'Upphædd (sats)',
faucest_wallet: 'Tiltaksmappa',
faucest_wallet_desc_1:
'Fyri hvørt gjald váttað av {provider} verður upphæddin drigin frá hesi mappuni.',
faucest_wallet_desc_2:
'Hetta hjálpir at halda skil á øllum {provider} gjøldum og støðum teirra.',
faucest_wallet_desc_3:
'Mappan skal hava upphædd av sats ið umsitarin bjóðar í býti fyri fiat gjaldoyrað.',
faucest_wallet_desc_4:
'Er ásetta mappan tóm verða gjøld umvegis {provider} ikki avgreidd.',
faucest_wallet_desc_5:
'Upphæddin á tiltaksmappuni kann umsíðir gerast negativ í fall fleiri fiat gjøld verða rindaði.',
faucest_wallet_id: 'Tiltaksmappueyðmerki (valfríur)',
faucest_wallet_id_hint:
'Eyðmerki á mappu við tøkari upphædd at senda til brúkaran.',
tag: 'Spjaldur',
unit: 'Eind',
description: 'Lýsing',
expiry: 'Fyrnar',
webhook: 'Vevongul',
webhook_url: 'Vevongulsleinkja',
webhook_url_hint:
'Vevongulsleinkja ið gjaldsupplýsingarnir verða sendir til. Tað verður koyrt fyri hvørt gjald.',
copy_webhook_url: 'Avrita vevongulsleinkju',
webhook_events_list: 'Vevongulin noyðist at koyra undir fylgjandi hendingum:',
webhook_stripe_description:
'Á Stripe síðuni noyðist tú at uppseta ein vevongul við einari leinkju ið peikar á tín LNbits-ambætara.',
webhook_square_description:
'Á Square síðuni noyðist tú at uppseta ein vevongul ið peikar á hesa LNbits-leinkjuna.',
square_webhook_url_hint:
'Skal samsvara við fráboðanarleinkjuna hjá Square. LNbits tørvar /api/v1/callback/square slóðina.',
access_token: 'Atgongdarmerki',
location_id: 'Staðsetingareyðmerki',
square_location_id_hint:
'Square-staðsetingareyðmerkið ið gjaldsumbønarleinkjur verða gjørdar fyri. Brúka endapunktið at velja royndar- ella framleiðsluumhvørvi.',
api_version: 'API útgáva',
payment_proof: 'Gjaldsprógv',
update: 'Dagfør', // This is used in terms of "updating extensions", and in terms of "Saving ACL changes"
update_available: 'Dagføring {version} tøk!',
funding_sources: 'Fíggingarkeldur',
funding_source: 'Fíggingarkelda',
requires_server_restart:
'Eftir broyting av hesum stillingunum er neyðugt at endurbyrja ambætaran, til tess at broytingarnar fáa virknað.',
funding_source_info: 'Fíggingarkelduupplýsingar',
phoenixd_warning:
'Phoenixd áminningarramsan kann einans ásetast um phoenixd dátuskjáttan er ásett og LNbits fær lisið hana. Tað er ikki eitt tekin um at phoenixd ikki koyrir. Tað merkir bara at LNbits ikki hevur atgongd at vísa áminningarramsuna her.',
latest_update: 'Tú ert á nýggjastu útgávuni {version}.',
notifications: 'Fráboðanir',
notifications_configure: 'Fráboðanaruppsetan',
notifications_nostr_config: 'Nostr uppsetan',
notifications_enable_nostr: 'Virkja Nostr fráboðanir',
notifications_enable_nostr_desc: 'Send fráboðanir umvegis Nostr',
notifications_nostr_private_key: 'Nostr privatur lykil',
notifications_nostr_private_key_desc:
'Privatur lykil, í sekstandatal ella nsec skapi, at undirrita boð send til Nostr',
notifications_nostr_identifier: 'Nostr-dátuheiti',
notifications_nostr_identifier_desc:
'Nip5-dátuheiti ið fráboðanir verða sendar til',
notifications_nostr_identifiers: 'Nostr-dátuheitir',
notifications_nostr_identifiers_desc:
'Listi av dátuheitum ið fráboðanir verða sendar til',
notifications_telegram_config: 'Telegram-uppsetan',
notifications_enable_telegram: 'Virkja Telegram-fráboðanir',
notifications_enable_telegram_desc: 'Send fráboðanir umvegis Telegram',
notifications_telegram_access_token: 'Atgongdarmerki',
notifications_telegram_access_token_desc: 'Atgongdarmerki til bottin',
notifications_chat_id: 'Telegram-kjatteyðmerki',
notifications_chat_id_desc:
'Eyðmerkið á Telegram-kjatti ið fráboðanir verða sendar til',
notifications_excluded_wallets_desc: 'Send ikki fráboðanir fyri hesar mappur',
notifications_email_config: 'Teldupost uppsetan',
notifications_enable_email: 'Virkja teldupost',
notifications_enable_email_desc: 'Send fráboðanir o.a. umvegis teldupost',
notifications_send_test_email: 'Send royndar teldubræv',
notifications_send_email: 'Send teldupost frá',
notifications_send_email_desc: 'Teldupostbústaður ið sent verður frá',
notifications_send_email_username: 'Brúkaranavn',
notifications_send_email_username_desc:
'Brúkaranavn. Verður brúkaranavn ikki tilskila verður teldupostbústaðurin brúktur',
notifications_send_email_password: 'Loyniorð fyri at senda teldupost',
notifications_send_email_password_desc:
'Loyniorð fyri teldupostbústaðin ið sent verður frá',
notifications_send_email_server_port: 'SMTP-portur',
notifications_send_email_server_port_desc: 'Portur á SMTP-ambætaranum',
notifications_send_email_server: 'SMTP-ambætari',
notifications_send_email_server_desc:
'SMTP-ambætari ið skal senda teldupostin',
notifications_send_to_emails: 'Send teldubrøv til',
notifications_send_to_emails_desc:
'Fráboðanir verða sendar, sum teldubrøv, til',
notification_settings_update: 'Broyttar stillingar',
notification_settings_update_desc: 'Fráboða um broyttar ambætarastillingar',
notification_server_start_stop: 'Startaðan/Steðgaðan ambætara',
notification_server_start_stop_desc:
'Fráboða um startaðan og steðgaðan ambætara',
notification_watchdog_limit: 'Varðhundsmarkfráboðan',
notification_watchdog_limit_desc:
'Boða frá tá mark varðhundsins er rokkið. Hetta broytir ikki fíggingarkelduna.',
notification_server_status: 'Ambætarastøðu',
notification_server_status_desc:
'Fráboða regluliga um ambætarastøðuna (tilskila tíðarbil í tímum)',
notification_incoming_payment: 'Inngjøld',
notification_incoming_payment_desc:
'Fráboða um inngjøld, á mappur, ið er hægri enn tilskillaða upphæddin (sats)',
notification_outgoing_payment: 'Útgjøld',
notification_outgoing_payment_desc:
'Fráboða um útgjøld, frá mappum, ið er størri enn tilskilaða upphæddin (sats)',
notification_credit_debit: 'Góð- og skuldskrivingar',
notification_credit_debit_desc:
'Fráboða um mappur góðskrivaðar ella skuldskrivaðar av úrvalsbrúkaranum',
notification_balance_delta_changed: 'Saldumunur broyttur',
notification_balance_delta_changed_desc:
'Boða frá tá munurin á knútasalduni og LNbits-salduni er broyttur meira enn tað ávístu upphæddina (í sats). Áset 0 fyri at óvirkja. Verður kannað hvønn minutt.',
watchdog_introduction:
'Varðhundurin er ein funka ið sjálvvirkandi kann skifta fíggingarkelduna til VoidWallet, í fall munurin á saldu fíggingarkeldunnar og saldu LNbits er størri enn eitt vist. Hetta kann hjálpa at fyribyrgja ovurnýtslu og tryggja saldu fíggingarkeldunnar.',
enable_watchdog: 'Varðhundaskifti',
enable_watchdog_desc:
'Um virkt, og LNbits-saldan gerst hægri enn knútasaldan, so verður fíggingarkeldan sjálvvirkandi broytt til VoidWallet. Eftir dagføringar er neyðugt at virkja hetta aftur.',
watchdog_interval: 'Títtleiki varðhundsins',
watchdog_interval_desc:
'Títtleikin — í minuttum — har varðhundurin kannar, um skifti, treytað av saldumuninum [knúta_salda - lnbits_salda], er neyðugt.',
watchdog_delta: 'Delta varðhundsins',
watchdog_delta_desc:
'Mark fyri skifti av fíggingarkeldu til VoidWallet [lnbits_salda - knúta_salda > delta]',
status: 'Støða',
notification_source: 'Fráboðanarkeldur',
notification_source_label:
'Kelduleinkja (brúka einans almennu LNbits fráboðanarkelduna og keldur tú hevur álit á)',
more: 'Frætt meira',
more_count: '{count} afturat',
less: 'minni',
releases: 'Útgávur',
watchdog: 'Varðhundur',
server_logs: 'Gerðalisti ambætarans',
ip_blocker: 'IP-noktan',
security: 'Trygd',
security_tools: 'Trygdaramboð',
block_access_hint: 'Nokta atgongd frá IP-atsetri',
allow_access_hint:
'Loyv atgongd frá IP-atsetri (hevur hægri raðfesting enn noktaði IP-atsetur)',
enter_ip: 'Inntøppa IP-atsetur og trýst Enter',
rate_limiter: 'Umbønaravmarkan',
callback_url_rules: 'Afturtøkukallleinkjureglur',
enter_callback_url_rule:
"Inntøppa leinkjuregul í regex forsniði og trýst 'enter'",
callback_url_rule_hint:
'Afturtøkukallleinkjur, harímillum LNURL-leinkjur, verða kannaðar sambært hesum reglunum. Í minsta lagi ein regla skal lúkast. Eru ongar reglur skrásettir eru allar leinkjur loyvdar.',
wallet_limiter: 'Mappuavmarkingar',
wallet_config: 'Mappustillingar',
wallet_charts: 'Mapputalvur',
wallet_limit_max_withdraw_per_day:
'Hámark á dagligum útgjøldum í sats (0 merkir óavmarkað, -1 noktar fyri útgjøldum)',
wallet_max_ballance: 'Mappusalduhámark í sats (0 merkir óavmarkað)',
wallet_limit_secs_between_trans:
'Lágmark fyri sekund ímillum flytingar fyri hvørja mappu (0 merkir einki mark)',
only_incoming_payments_allowed: 'Loyv bert inngjøldum',
disable_outgoing_payments: 'Óvirkja útgjøld',
number_of_requests: 'Tal av umbønum',
number_of_requests_hint:
'Loyvdar umbønir fyri hvørt "tíðarbil" hjá umbønaravmarkaranum. Áset 0 fyri at óvirkja umbønaravmarkaran.',
time_unit: 'Tíðarbil',
minute: 'minutt',
settings: 'Stillingar',
second: 'sekund',
hour: 'tíma',
disable_server_log: 'Óvirkja gerðalista ambætarans',
enable_server_log: 'Virkja gerðalista ambætarans',
coming_soon: 'Hentleikin er undir menning',
session_has_expired: 'Tín seta er útgingin. Vinaliga rita innaftur.',
instant_access_question: 'ella stundisliga atgongd',
login_with_user_id: 'Rita inn við brúkaraeyðmerki',
or: 'ella',
create_new_wallet: 'Ger nýggja mappu',
delete_all_wallets: 'Strika allar mappur',
confirm_delete_all_wallets:
'Ynskir tú at strika ALLAR mappurnar hjá hesum brúkaranum?',
login_to_account: 'Rita inn á tín brúkara',
create_account: 'Stovna brúkara',
account_settings: 'Brúkara uppsetan',
signin_with_oauth: 'Rita inn við',
signin_with_oauth_or: 'ella rita inn við',
signin_with_nostr: 'Rita inn við Nostr',
signin_with_google: 'Rita inn við Google',
signin_with_github: 'Rita inn við GitHub',
signin_with_custom_org: 'Rita inn við {custom_org}',
username_or_email: 'Brúkaranavn ella teldupostbústaður',
password: 'Loyniorð',
password_config: 'Loyniorðsuppsetan',
password_repeat: 'Endurtak loyniorðið',
update_password: 'Broyt loyniorðið',
change_password: 'Broyt loyniorð',
update_credentials: 'Dagfør innritanarupplýsingar',
update_pubkey: 'Broyt almenna lykilin',
nostr_pubkey_tooltip: 'Inntøppa almenna Nostr-lykil brúkarans (sekstandatøl)',
set_password: 'Áset loyniorð',
set_password_tooltip: 'Áset hesum brúkaranum eitt loyniorð',
invalid_password: 'Loyniorð skulu hava í minsta lagi 8 tekn',
invalid_password_repeat: 'Loyniorðini eru ikki eins',
reset_key_generated: 'Ein endursetanarlykil er hervið gjørdur.',
reset_key_copy:
'Trýst á OK fyri at avrita endursetanarleinkjuna til setiborðið.',
login: 'Rita inn',
register: 'Skráset',
username: 'Brúkaranavn',
pubkey: 'Almennur lykil',
user_id: 'Brúkaraeyðmerki',
id: 'Eyðmerki',
email: 'Teldupostbústaður',
email_confirmation_hint:
'Teldupostbústaður ið váttanarkota skal sendast til.',
nostr_identifier: 'Nostr-dátuheiti',
nostr_identifier_hint:
'Nostr nip5-dátuheiti ella <npub> ið váttanarkota skal sendast til.',
first_name: 'Fornavn',
last_name: 'Eftirnavn',
picture: 'Mynd',
user_picture_desc:
'Leinkja ið vísur til vangamynd. Tú kanst leggja hana út undir Tilfar.',
verify_email: 'Vátta teldupost umvegis',
account: 'Brúkari',
update_account: 'Broyt brúkara',
invalid_username: 'Ógildigt brúkaranavn',
auth_provider: 'Samgildisveitari',
external_id: 'Ytri eyðmerki',
my_account: 'Mín brúkari',
existing_account_question: 'Hevur tú longu ein brúkara?',
background_image: 'Bakgrundsmynd',
back: 'Aftur',
logout: 'Rita út',
look_and_feel: 'Útsjónd',
endpoint: 'Endapunkt',
api: 'API',
api_stripe: 'API',
api_token: 'API-merki',
api_tokens: 'API-merkir',
access_control_list: 'Atgongdarstýringarlisti',
access_control_list_admin_warning:
'Hetta er ein umsitanarbrúkari. Framleidd merkir fáa tí umsitanarrættindir.',
new_api_acl: 'Nýggjan atgongdarstýringarlista',
api_token_id: 'API-merki',
toggle_gradient: 'Litskifti',
gradient_background: 'Litskifti á bakgrund',
rounded_ui: 'Avrundaði kort & knøttar',
toggle_rounded_ui: 'Virkja ella óvirkja avrundaði horn á kortum og knøttum',
card_gradient: 'Litskifti á kortum',
toggle_card_gradient: 'Virkja ella óvirkja litskifti á kortum',
card_shadow: 'Kortskuggar',
toggle_card_shadow: 'Virkja ella óvirkja skugga handan kort',
burger_menu_background: 'Bakgrund á síðuteigi',
toggle_burger_menu_background: 'Virkja ella óvirkja bakgrund síðuteigsins',
language: 'Mál',
assets: 'Tilfar',
max_asset_size_mb: 'Hámarksstødd á tilfari (MB)',
max_asset_size_mb_desc:
'Hámarksstøddin, í megabýtum, á tilfari ið verður uppsent. Møguligt er at nýta desimalar.',
assets_allowed_mime_types: 'Loyvt slag av tilfari',
assets_allowed_mime_types_desc:
'(MIME) Sløg av tilfari ið loyvt verður at uppsenda. Um einki slag er ásett eru øll sløg loyvd.',
thumbnail_width: 'Smámyndavídd',
thumbnail_width_desc: 'Víddin á framleiddu smámyndunum, í piksilum.',
thumbnail_height: 'Smámyndahædd',
thumbnail_height_desc: 'Hæddin á framleiddu smámyndunum, í piksilum.',
thumbnail_format: 'Smámyndaforsnið',
thumbnail_format_desc:
'Forsnið á framleiddu smámyndunum (PNG, JPEG, o.s.fr.).',
max_assets_per_user: 'Hámark av tilfari fyri hvønn brúkara',
max_assets_per_user_desc:
'Hámark av tilfari ið hvør brúkari sleppur at uppsenda. Null merkir at uppsending er noktað.',
assets_no_limit_users: 'Brúkarir undantiknir tilfarsmørkum',
assets_no_limit_users_desc:
'Hesir brúkarir eru undantiknir hámarkinum av uppsendum tilfari (grundað á brúkaraeyðkenni).',
color_scheme: 'Litaval',
visible_wallet_count: 'Nøgd av vístum mappum',
admin_settings: 'Umsit stillingar',
extension_cost: 'Útgávan krevur eitt gjald á í minsta lagi {cost} sats.',
extension_paid_sats: 'Tú hevur longu goldið {paid_sats} sats.',
extension_permissions_title: 'Játta ískoytisforriti rættindir',
extension_permissions_tab: 'Rættindir ískoytisforritsins',
user_permissions_tab: 'Mínar játtanir',
extension_permissions_none:
'Hetta ískoytisforritið krevur ongi rættindir undir innlegging.',
user_permissions_none:
'Tú hevur ikki játtað hesum ískoytisforriti nøkur rættindir.',
user_permissions_max_amount: 'Mark á gjaldsupphædd',
user_permissions_destination_policy: 'Loyvi at rinda til',
user_permissions_no_editable_settings:
'Hendan játtanin hevur einki at tillaga.',
extension_permissions_grant_install: 'Játta og innlegg',
extension_permissions_high_risk_warning:
'Hetta ískoytisforritið biður um rættindi at flyta pening.',
extension_permission_risk_low: 'Lítil váði',
extension_permission_risk_medium: 'Miðal váði',
extension_permission_risk_high: 'Stórur váði',
extension_permission_warning_wallet_pay_invoice:
'Kann brúka pening úr mappum ið eru tøkar á tínum brúkara.',
extension_permission_warning_wallet_pay_invoice_background:
'Kann brúka pening úr ásettum mappum seinni og uttan inntriv frá brúkara.',
extension_permission_warning_wallet_payments_watch:
'Kann lesa gjaldssmálutir viðvíkjandi ásettum mappum.',
extension_permission_warning_extension_api_request_write:
'Kann skriva dátur ella koyra tilgongdir í ásettum ískoytisforritum.',
extension_permission_ext_storage_read: 'Lesa ískoytisforritagoymslu',
extension_permission_ext_storage_read_public:
'Lesa almenna ískoytisforritagoymslu',
extension_permission_ext_storage_write: 'Skriva til ískoytisforritagoymslu',
extension_permission_ext_storage_read_write:
'Lesa og skriva til ískoytisforritagoymslu',
extension_permission_extension_api_request: 'Brúka onnur ískoytisforrit',
extension_permission_extension_api_request_extensions: 'Loyvd ískoytisforrit',
extension_permission_access_read: 'Lesa',
extension_permission_access_write: 'Skriva',
extension_permission_http_request: 'Sambinda til ytri vevsíður',
extension_permission_http_request_hosts: 'Loyvdir vertir',
extension_permission_utils_basic: 'Brúka grundleggjandi LNbits hentleikar',
extension_permission_ui_camera_scan_qr: 'Skanna QR-kotur',
extension_permission_wallet_payments_watch: 'Eygleiða gjøld á mappum',
extension_permission_wallet_create_invoice: 'Gera gjaldsumbønir',
extension_permission_wallet_create_invoice_public:
'Gera Lightning-gjaldsumbønir frá almennum síðum',
extension_permission_wallet_balance_read: 'Síggja mappusaldur',
extension_permission_wallet_list: 'Vísa mappur',
extension_permission_wallet_pay_invoice: 'Rinda gjaldsumbønir',
extension_permission_wallet_pay_invoice_background:
'Rinda í bakgrundini, uttan inntriv frá brúkara',
create_extension: 'Stovna ískoytisforrit',
release_details_error: 'Bar ikki til at útvega sleppingarstaklutir.',
pay_from_wallet: 'Rinda úr mappuni',
pay_with: 'Rinda við {provider}',
select_payment_provider: 'Vel gjaldsmeklara',
wallet_required: 'Mappa *',
show_qr: 'Vís QR-kotu',
retry_install: 'Royn at leggja inn aftur',
new_payment: 'Rinda av nýggjum',
update_payment: 'Goym gjaldsupplýsingar',
already_paid_question: 'Hevur tú longu goldið?',
sell: 'Sel',
sell_require: 'Virkja ískoytisforritið fyri eitt gjald',
sell_info:
'Eitt gjald á í minsta lagi {amount} sats er kravt fyri at virkja {name}-ískoytisforritið.',
hide_empty_wallets: 'Fjal tómar mappur',
recheck: 'Eftirkanna',
check: 'Kanna',
check_connection: 'Kanna sambinding',
check_webhook: 'Kanna vevongul',
contributors: 'Stigtakarir',
license: 'Loyvi',
reset_key: 'Endursetanarlykil',
reset_password: 'Endurset loyniorð',
border_choices: 'Rammusnið',
select_all: 'Vel øll',
nfc_supported: 'NFC-hentleiki er møguligur',
nfc_not_supported: 'NFC-hentleiki er ikki møguligur',
expire_date: 'Útgongudagfesting: ',
hash: 'Hash: ',
welcome_lnbits: 'Vælkomin til LNbits',
setup_su_account: 'Ger úrvalsbrúkaran niðanfyri.',
first_install_token: 'Merkið fyri fyrstu innlegging',
create_ticker_converter: 'Ger gjaldoyramerkilíka',
enable_audit: 'Virkja slóðfesti',
recommended: 'Viðmælt',
audit_desc: 'Skráset HTTP-umbønirnar sambært fylgjandi ásetingum',
audit_record_req: 'Skráset body-partin av umbønum',
audit_record_warning: 'Gev gætur: ',
audit_record_req_warning_1:
'trúnaðardátur, teirra millum loyniorð, verða skrásettar.',
audit_record_req_warning_2: 'body-parturin av umbønunum kann fylla nógv.',
audit_record_use: 'Skal brúkast við fyrivarni.',
audit_ip: 'Skráset IP-atsetur',
audit_ip_desc: 'Skráset IP-atsetur viðskiftarans',
audit_path_params: 'Skráset slóðávirkir (Path Parameters)',
audit_query_params: 'Skráset fyrispurningsávirkir (Query Parameters)',
audit_http_methods: 'Íroknaðir HTTP-háttir',
audit_http_methods_hint:
'Íroknaðir HTTP-háttir. Er listin tómur verða allir háttir íroknaðir.',
audit_http_methods_label: 'HTTP-háttir',
audit_resp_codes: 'Íroknaðar HTTP-svarkotur',
audit_resp_codes_hint:
'Íroknaðar HTTP-svarkotur (regex forsnið). Er listin tómur verða allar kotur íroknaðar. T.d.: 4.*, 5.*',
audit_resp_codes_label: 'HTTP-svarkotur (regex)',
audit_paths: 'Íroknaðar slóðir',
audit_paths_hint:
'Íroknaðar slóðir (regex forsnið). Er listin tómur verða allar slóðir íroknaðar.',
audit_paths_label: 'HTTP-slóð (regex)',
audit_paths_exclude: 'Útiloka slóðir',
audit_paths_exclude_hint:
'Listi av útilokaðum slóðum (regex forsnið). Er listin tómur verða ongar slóðir útilokaðar.',
audit_paths_exclude_label: 'HTTP-slóð (regex)',
exchange_providers: 'Gjaldoyrakursveitarir',
admin_extensions: 'Umsitingarískoytisforrit',
admin_extensions_label: 'Umsitingarískoytisforrit',
admin_extensions_hint:
'Ískoytisforrit ið einans er tøk fyri brúkarum við umsitingarligum rættindum',
user_default_extensions: 'Brúkaraforsett ískoytisforrit',
user_default_extensions_label: 'Brúkaraforsett ískoytisforrit',
user_default_extensions_hint:
'Ískoytisforrit ið forsett verða virkt fyri brúkarunum',
extension_builder: 'Ískoytisforritasavnari',
extension_builder_manifest_url: 'Leinkja til ískoytisforritasavnaraskrá',
extension_builder_manifest_url_hint:
'Leinkja til eina JSON-skrá við ískoytisforritasavnarastaklutum',
miscellanous: 'Ymiskt',
misc_disable_extensions: 'Óvirkja ískoytsiforrit',
misc_disable_extensions_label: 'Óvirkja øll ískoytisforrit',
misc_disable_extensions_builder: 'Virkja ískoytisforritasavnara',
misc_disable_extensions_builder_label:
'Lova brúkarum ið ikki hava umsitanarrættindi at brúka ískoytisforritasavnaran',
misc_hide_api: 'Fjal API-upplýsingar',
misc_hide_api_label:
'Fjalur mappu-API. Tað er upp til hvørt ískoytisforrit sær at halda við stillingini',
wallets_management: 'Fíggjaruppsetan',
funding_source_info: 'Fíggingarkeldu upplýsingar',
funding_source: 'Fíggingarkelda: {wallet_class}',
node_balance: 'Salda knútsins: {balance} sats',
lnbits_balance: 'LNbits-salda: {balance} sats',
funding_reserve_percent: 'Tiltakspeningur: {percent} %',
node_management: 'Knútaumsiting',
node_management_not_supported:
'Knútaumsiting er ikki møgulig við verandi fíggingarkeldu',
toggle_node_ui: 'Knútanýtslumót',
toggle_public_node_ui: 'Alment knútanýtslumót',
toggle_transactions_node_ui:
'Flytingarskiljiblað (frámælt á størri CLN-knútum vegna ovbyrjan)',
invoice_expiry: 'Loka gjaldsumbøn eftir',
routing_fee_reserve_calculations: 'Beiningaravgjaldstiltaksútrokningar',
routing_fee_reserve_calculations_desc:
'Fyri hvørt útgjald setur LNbits eina "tiltaksupphædd" til síðis at rinda beiningaravgjøld. Hámarkið á beiningaravgjaldinum ið verður handað fíggingarkelduni er tað hægra av fylgjandi: <strong>lágmarkið fyri beiningaravgjald</strong> ella <strong>beiningaravgjald í prosentum</strong>.',
millisats: 'millisats',
fee_reserve: 'Lágmark fyri beiningaravgjald',
fee_reserve_percent: 'Beiningaravgjald í prosentum',
fee_reserve_min_hint:
'Lágmark fyri beiningariavgjald fyri hvørt gjald.<br>Hetta riggar sum eitt minstamark - mest loyvda beiningaravgjald verður ongantíð lægri, uttan mun til gjaldsupphæddina.',
fee_reserve_percent_hint:
'Prosent av gjaldsupphæddini at seta av til beiningaravgjald.',
payment_timeouts: 'Gjaldsfreistir',
payment_wait_time: 'Greiðslubíðitíð (sek.)',
seconds: 'sekund',
payment_pending_interval: 'Greiðslukanningarmillumbil (sek.)',
payment_pending_interval_desc: 'Tíðin ímillum óavgreidd gjøld verða kannaði',
payment_pending_interval_tooltip:
'Ásetur títtleikan ið LNbits kannar og tillagar støðuna á óavgreiddum gjøldum. Longri millumbil kunnu lætta um byrðu knútsins og elva til skjótari greiðslur, ímeðan støðan á óavgreiddum gjøldum ikki verður dagførd líka títt.',
payment_wait_time_desc:
'Bíðitíð áðrenn útgjøld verða merkt at verða í bíðistøðu. Forsett: 5 sek.; hækka í fall gjaldsumbønirnar taka langa tíð at avroknað.',
payment_wait_time_tooltip:
'Ásetur tíðina ið LNbits bíðar eftir váttan fyri eitt útgjáld áðrenn útgjaldið verður sett í bíðistøðu. Longri bíðitíðir eru hóskandi tá ein skal rinda gjaldsumbønir ið taka longri tí at avroknað (t.d. HODL-gjaldsumbønir, Boltz). Møguligt er at kannað útgjaldið seinni og tað verður eisini kannað sjálvvirkandi við jøvnum millumbilum.',
server_management: 'Ambætaraumsiting',
base_url_label: 'Fastbundin rótleinkja til hendan ambætaran',
authentication: 'Samgilding',
auth_token_expiry_label: 'Merkigildistíð í minuttum',
auth_token_expiry_hint: 'Minuttir ið merkir eru gildið',
auth_authentication_cache_label: 'Kovatíð (minuttir)',
auth_authentication_cache_hint:
'Minuttir ið eydnaðar samgildingar verða goymdar í kovanum (áset 0 fyri at óvirkja)',
auth_allowed_methods_label: 'Loyvdir samgildisháttir',
auth_allowed_methods_hint: 'Vel samgildisháttir',
auth_nostr_label: 'Nostr-umbønarleinkja',
auth_nostr_hint:
'Fullfíggjaðar leinkjur ið viðskiftarar skulu brúka til innritanir.',
auth_google_ci_label: 'Google-viðskiftaraeyðmerki',
auth_google_ci_hint:
'Tryggja at góðkenda víðaribeiningin inniheldur https://{domain}/api/v1/auth/google/token',
auth_google_cs_label: 'Google-viðskiftaraloyna',
auth_gh_client_id_label: 'GitHub-viðskiftaraeyðmerki',
auth_gh_client_id_hint:
'Tryggja at samgildisafturtøkukallsleinkjan er sett til https://{domain}/api/v1/auth/github/token',
auth_gh_client_secret_label: 'GitHub-viðskiftaraloyna',
auth_keycloak_label: 'Keycloak-viðrakanarleinkja',
auth_keycloak_ci_label: 'Keycloak-viðskiftaraeyðmerki',
auth_keycloak_ci_hint:
'Tryggja at samgildisafturtøkukallsleinkjan er sett til https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak-viðskiftaraloyna',
auth_keycloak_custom_org_label: 'Tillaga Keycloak-felag',
auth_keycloak_custom_icon_label: 'Tillaga Keycloak ímynd (leinkja)',
auth_oidc_label: 'OIDC-viðrakanarleinkja',
auth_oidc_ci_label: 'OIDC-viðskiftaraeyðmerki',
auth_oidc_ci_hint:
'Tryggja at samgildisafturtøkukallsleinkjan er sett til https://{domain}/api/v1/auth/oidc/token',
auth_oidc_cs_label: 'OIDC-viðskiftaraloyna',
auth_oidc_custom_org_label:
'Tillaga OIDC navn á felagi (t.d. Zitadel, Authentik)',
auth_oidc_custom_icon_label: 'Tillaga OIDC ímynd (leinkja)',
currency_settings: 'Gjaldoyrastillingar',
allowed_currencies: 'Loyvd gjaldoyru',
allowed_currencies_hint: 'Avmarka tøk fiat gjaldoyru',
default_account_currency: 'Forsett roknskapargjaldoyra',
default_account_currency_hint: 'Forsett gjaldoyra til roknskaparførslu',
min_incoming_payment_amount: 'Inngjaldslágmark',
min_incoming_payment_amount_desc:
'Minst loyvda upphædd tá gjaldsumbøn verður gjørd',
max_incoming_payment_amount: 'Inngjaldshámark',
max_incoming_payment_amount_desc:
'Hægst loyvda upphædd tá gjaldsumbøn verður gjørd',
max_outgoing_payment_amount: 'Útgjaldshámark',
max_outgoing_payment_amount_desc:
'Hægst loyvda upphædd á hvørjum útgjaldi sær',
service_fees: 'Tænastuavgjøld',
service_fee: 'Tænastuavgjald',
service_fee_label: 'Tænastuavgjald (%)',
service_fee_hint: 'Avgjald kravt fyri hvørja flyting (%)',
service_fee_max: 'Hámark fyri tænastuavgjald',
service_fee_max_label: 'Hámark fyri tænastuavgjald (sats)',
service_fee_max_hint: 'Hámark fyri kravt tænastugjald í satoshis',
fee_wallet: 'Avgjaldsmappa',
fee_wallet_label: 'Avgjaldsmappa (mappueyðmerki)',
fee_wallet_hint: 'Eyðmerki á mappu ið tænastuavgjøld verða góðskrivað á',
disable_fee: 'Óvirkja tænastuavgjald',
disable_fee_internal: 'Óvirkja tænastuavgjald fyri innanknútsins gjøld',
disable_fee_internal_desc:
'Óvirkja tænastuavgjald fyri innanknútsins Lightning gjøld',
ui_management: 'Nýtslumótumsiting',
ui_site_title: 'Síðuheiti',
ui_changing_remove_lnbits_elements:
' (tillaging elvir til at LNbits-lutir á forsíðuni og síðufótinum verða strikaðir)',
ui_site_tagline: 'Síðuslagorð',
ui_elements_enable: 'Vís lutir á forsíðufótinum',
ui_elements_disable: 'Fjal lutir á forsíðufótinum',
ui_toggle_elements_tip:
"Strikar lutir so sum LNbits-útgávu og 'Koyrir á' frá forsíðuni",
ui_site_description: 'Síðulýsing',
ui_site_description_hint: 'Nýt vanligan tekst, Markdown, ella rátt HTML',
ui_default_wallet_name: 'Forsett mappunavn',
ui_default_theme: 'Forsett snið',
wallet_featured_button_title: 'Tillagaður knøttur á mappusíðuni',
wallet_featured_button_label: 'Tekstur á tillagaum knøtti',
wallet_featured_button_label_hint: 'Vís sermerktan knøtt á mappusíðuni',
wallet_featured_button_url: 'Leinkja ið tillagi knøtturin peikar á',
wallet_featured_button_url_hint:
'Leinkjan verður latin upp tá trýst verður á knøttin. Lat teigin verða tóman fyri at fjala knøttin.',
wallet_featured_button_icon: 'Ímynd á tillagaða knøttinum',
wallet_featured_button_icon_hint:
'Quasar-ímyndarnavnið ið skal vísast á knøttinum (t.d. "bolt")',
lnbits_wallet: 'LNbits-mappa',
denomination: 'Heiti',
denomination_hint: 'Heitið á FakeWallet-myntlíkinum',
denomination_error: 'Heitið skal verða 3 stavir, ella `sats`',
ui_qr_code_logo: 'Ímynd á QR-kotum og snarvegum',
ui_qr_code_logo_hint:
'Leinkja til ímynd ið skal brúkast á QR-kotum og snarvegum',
ui_apple_touch_icon: 'Apple Touch ímynd',
ui_apple_touch_icon_hint: 'Leinkja til Apple touch ímynd',
ui_custom_image: 'Tillagað mynd',
ui_custom_image_label: 'Leinkja til tillagaða mynd',
ui_custom_image_hint: 'Mynd víst á forsíðu/innritanarsíðu',
ui_custom_badge_title: 'Tillaga spjaldur',
ui_custom_badge_desc: 'Vís tillagaðan spjaldratekst ovast á LNbits síðuni',
ui_custom_badge: 'Tillagaður spjaldratekstur',
ui_custom_badge_label: "Tillagaður spjaldratekstur 'BRÚKA VIÐ FYRIVARNI'",
ui_custom_badge_color_label: 'Litur á spjaldrinum',
themes: 'Snið',
themes_hint: 'Tilskilaði snið ið verða tøk fyri brúkarum',
custom_logo: 'Umsitaravalt búmerki',
custom_logo_hint: 'Leinkja ið peikar til búmerkið',
ad_space_section_title: 'Lýsingarteigur',
ad_space_section_desc: 'Tillaga lýsingarteigin á mappusíðuni.',
ad_space_title: 'Tekstur á lýsingarteigi',
ad_space_title_hint: 'Tekstur vístur omanfyri lýsingarteigin',
ad_slots: 'Lýsingar',
ad_slots_hint:
'Leinkjur og myndaleinkjur í CSV-forsniði. Tað er upp til hvørt ískoytisforrit sær at halda við stillingini.',
ads_enabled: 'Vís lýsingar',
ads_disabled: 'Fjal lýsingar',
user_management: 'Brúkaraumsiting',
admin_users: 'Umsitarir',
admin_users_hint: 'Brúkarar við umsitingarrættindum',
admin_users_label: 'Brúkaraeyðmerki',
allowed_users: 'Loyvdir brúkarar',
allowed_users_hint: 'Einans fylgjandi brúkarar kunnu nýta LNbits',
allowed_users_hint_feature: '{feature} er avmarkað til hesar brúkararnar',
allowed_users_label: 'Brúkaraeyðmerki',
allow_creation_user: 'Loyv skráseting av nýggjum brúkarum',
allow_creation_user_desc:
'Loyv stovnan av nýggjum brúkarum umvegis forsíðuna',
require_user_activation: 'Krev virkjan av nýggjum brúkarum',
require_user_activation_desc:
'Nýggir brúkarir verða virktir við at lúka eina av váttanartreytunum. Umsitarir kunnu virkja brúkarir frá umsitanarsíðuni og harvið skúgva váttanartreytirnar til viks fyri brúkarar.',
reusable_activation_code: 'Endurnýtsluvirkjanarkota',
reusable_activation_code_label: 'Endurnýtsluvirkjanarkota',
reusable_activation_code_hint:
'Hendan virkjanarkotan kann nýtast fleiri ferðir av ymiskum brúkarum.',
one_time_activation_code: 'Einnýtisvirkjanarkotur',
one_time_activation_code_label: 'Legg virkjanarkotu inn',
one_time_activation_code_hint:
'Einnýtisvirkjanarkotur. Hvør kota kann bert nýtast einaferð og verður strika úr listanum eftir at hon er brúkt.',
invitation_code: 'Innbjóðingarkota',
invitation_code_hint: 'Innbjóðingarkotan ið tú hevur fingið.',
new_user_not_allowed: 'Skráseting av nýggjum brúkarum er óvirkt.',
start_user_impersonation: 'Lát at vera hesin brúkarin',
stop_user_impersonation: 'Lát ikki longur at vera brúkari',
components: 'Forritsliðir',
long_running_endpoints: '5 endapunktini ið hava koyrt longst',
http_request_methods: 'HTTP-umbønarháttir',
http_response_codes: 'HTTP-svarkotur',
request_details: 'Smálutir umbønarinnar',
http_request_details: 'HTTP-umbønarsmálutir',
payment_details: 'Gjaldssmálutir',
payment_details_desc: 'Nágreiniligir staklutir gjaldsins',
payments: 'Gjøld',
payment_show_internal: 'Vís innanhýsis gjøld',
payment_chart_flow: 'Mánaðarligur peningastreymur',
payment_chart_status: 'Gjaldstøður',
payment_chart_tx_per_wallet: 'Flytingar fyri hvørja mappu (upphædd/nøgd)',
payment_details_back: 'Aftur til gjøld',
payment_chart_tags: 'Gjøld eftir spjøldrum',
payments_balance_in_out: 'Inn- og útgjaldsupphæddir',
payments_count_in_out: 'Nøgd av inn- og útgjøldum',
payments_status_chart: 'Støðumynd',
payments_tag_chart: 'Spjaldrasirkulmynd',
payments_balance_chart: 'Saldulinjumynd',
payments_wallets_chart: 'Mappumynd',
payments_balance_in_out_chart: 'Inn- og útgjaldsupphæddir',
payments_count_in_out_chart: 'Nøgd av inn- og útgjøldum',
reset_wallet_keys: 'Endurset lyklar',
reset_wallet_keys_desc:
'Endurset API lyklarnar fyri mappuna. Hetta ógildar verandi lyklar og framleiður nýggjar lyklar.',
view_list: 'Vís mappur í lista',
view_column: 'Vís mappur í teigum',
filter_payments: 'Filtrera gjøld',
filter_labels: 'Filtrera spjøldur',
filter_date: 'Filtrera út frá tíðarskeiði',
websocket_example: 'Vevsokkul dømi',
client_id: 'Viðskiftaraeyðmerki',
secret_key: 'Loyniligur lykil',
signing_secret: 'Undirritanarlykil',
signing_secret_hint:
'Undirritanarlykil fyri vevongulin. Boð verða undirritaði við hesum lyklinum.',
webhook_id: 'Vevongulseyðmerki',
webhook_id_hint: 'PayPal-vevongulseyðmerki ið váttar inngangandi hendingar.',
webhook_paypal_description:
'Uppset ein vevongul ið peikar á tín LNbits-ambætara á PayPal-síðuni.',
square_webhook_signature_key_hint:
'Square-vevongulsundirritanarlykil ið váttar inngandi hendingar.',
callback_success_url: 'Afturtøkukallsleinkja',
callback_success_url_hint:
'Eftir avgreitt gjald verður brúkarin víðaribeindur til hesa leinkjuna',
connected: 'Sambundin',
not_connected: 'Ikki sambundin',
free: 'Ókeypis',
paid: 'Til keyps',
funding_source_retries: 'Hámark av endurroyndum',
funding_source_retries_desc:
'Hámark av endurroyndum av knýta í fíggingarkelduna áðrenn VoidWallet verður virkt.',
add_label: 'Nýtt spjaldur',
label: 'Spjaldur',
labels: 'Spjøldur',
label_filter: 'Spjaldrafiltur',
no_labels_defined: 'Enn eru eingi spjøldur gjørd',
manage_labels: 'Umsit spjøldur',
update_label: 'Broyt spjaldur',
delete_label: 'Strika spjaldur',
add_remove_labels: 'Áset ella strika spøldur',
payment_labels_updated: 'Spjøldur á gjaldi broytt',
color: 'Litur',
sort: 'Raða',
sort_by: 'Raða eftir'
}

View file

@ -75,6 +75,10 @@ window.localisation.fr = {
view_swagger_docs: "Voir les documentation de l'API Swagger de LNbits",
api_docs: "Documentation de l'API",
api_keys_api_docs: 'URL du nœud, clés API et documentation API',
api_keys_warning:
'Ces clés doivent être conservées en lieu sûr ; les partager pourrait entraîner la perte de fonds.',
admin_key_warning:
"Votre clé d'administrateur donne un accès complet à votre portefeuille, y compris la possibilité d'envoyer des paiements. Ne la partagez jamais, sauf si vous faites entièrement confiance au destinataire.",
lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur',
paste: 'Coller',
@ -456,5 +460,48 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande',
http_request_details: 'Détails de la requête HTTP'
http_request_details: 'Détails de la requête HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activer le Block Explorer',
block_explorer_desc:
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
blockexplorer_public_api: 'Accès API public',
blockexplorer_public_api_desc:
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
electrum_server_url: 'URL du serveur Electrum',
electrum_server_url_hint:
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
blockexplorer_search_hint:
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
recent_blocks: 'Blocs récents',
chain_tip: 'Sommet de chaîne',
block_height: 'Hauteur de bloc',
block_fee: 'frais de bloc',
fee_estimates: 'Estimations de frais',
confirmed_balance: 'Solde confirmé',
unconfirmed_balance: 'Solde non confirmé',
transaction_history: 'Historique des transactions',
coinbase: 'Coinbase',
inputs: 'Entrées',
outputs: 'Sorties',
confirmations: 'Confirmations',
confirmed: 'Confirmé',
unconfirmed: 'Non confirmé',
history_unavailable:
'Historique des transactions indisponible (adresse avec trop de transactions)',
address: 'Adresse',
block_number: 'Bloc #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloc précédent',
merkle_root: 'Racine de Merkle',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulté',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Taille virtuelle',
weight: 'Poids',
n_block_fee: 'frais {n} blocs'
}

View file

@ -73,6 +73,10 @@ window.localisation.it = {
view_swagger_docs: "Visualizza i documentazione dell'API Swagger di LNbits",
api_docs: "Documentazione dell'API",
api_keys_api_docs: 'URL del nodo, chiavi API e documentazione API',
api_keys_warning:
'Queste chiavi devono essere conservate al sicuro; condividerle potrebbe causare la perdita di fondi.',
admin_key_warning:
'La tua chiave di amministratore concede accesso completo al tuo portafoglio, inclusa la possibilità di inviare pagamenti. Non condividerla mai, a meno che tu non ti fidi completamente del destinatario.',
lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su',
paste: 'Incolla',
@ -450,5 +454,48 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta',
http_request_details: 'Dettagli della richiesta HTTP'
http_request_details: 'Dettagli della richiesta HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Abilita Block Explorer',
block_explorer_desc:
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
blockexplorer_public_api: 'Accesso API pubblico',
blockexplorer_public_api_desc:
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
electrum_server_url: 'URL server Electrum',
electrum_server_url_hint:
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
blockexplorer_search_hint:
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
recent_blocks: 'Blocchi recenti',
chain_tip: 'Punta della catena',
block_height: 'Altezza blocco',
block_fee: 'commissione blocco',
fee_estimates: 'Stime delle commissioni',
confirmed_balance: 'Saldo confermato',
unconfirmed_balance: 'Saldo non confermato',
transaction_history: 'Storico transazioni',
coinbase: 'Coinbase',
inputs: 'Input',
outputs: 'Output',
confirmations: 'Conferme',
confirmed: 'Confermato',
unconfirmed: 'Non confermato',
history_unavailable:
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
address: 'Indirizzo',
block_number: 'Blocco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Blocco precedente',
merkle_root: 'Radice di Merkle',
version: 'Versione',
bits: 'Bit',
difficulty: 'Difficoltà',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Dimensione virtuale',
weight: 'Peso',
n_block_fee: 'commissione {n} blocchi'
}

View file

@ -69,6 +69,10 @@ window.localisation.jp = {
view_swagger_docs: 'Swaggerドキュメントを表示',
api_docs: 'APIドキュメント',
api_keys_api_docs: 'ードURL、APIキー、APIドキュメント',
api_keys_warning:
'これらのキーは安全に保管してください。共有すると資金を失うおそれがあります。',
admin_key_warning:
'管理者キーは、支払いの送信を含むウォレットへの完全なアクセスを許可します。受取人を完全に信頼している場合を除き、決して共有しないでください。',
lnbits_version: 'LNbits バージョン',
runs_on: 'で実行',
paste: '貼り付け',
@ -440,5 +444,48 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細',
http_request_details: 'HTTPリクエストの詳細'
http_request_details: 'HTTPリクエストの詳細',
block_explorer: 'ブロックエクスプローラー',
enable_block_explorer: 'ブロックエクスプローラーを有効化',
block_explorer_desc:
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
blockexplorer_public_api: 'パブリックAPIアクセス',
blockexplorer_public_api_desc:
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
electrum_server_url: 'ElectrumサーバーURL',
electrum_server_url_hint:
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
blockexplorer_search_hint:
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
recent_blocks: '最新ブロック',
chain_tip: 'チェーン先端',
block_height: 'ブロック高さ',
block_fee: 'ブロック手数料',
fee_estimates: '手数料見積もり',
confirmed_balance: '確認済み残高',
unconfirmed_balance: '未確認残高',
transaction_history: 'トランザクション履歴',
coinbase: 'コインベース',
inputs: 'インプット',
outputs: 'アウトプット',
confirmations: '確認数',
confirmed: '確認済み',
unconfirmed: '未確認',
history_unavailable:
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
address: 'アドレス',
block_number: 'ブロック #{height}',
block_diff: 'diff {value}',
block_hash: 'ハッシュ',
previous_block: '前のブロック',
merkle_root: 'マークルルート',
version: 'バージョン',
bits: 'Bits',
difficulty: '難易度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '仮想サイズ',
weight: '重量',
n_block_fee: '{n}ブロック手数料'
}

View file

@ -71,6 +71,10 @@ window.localisation.kr = {
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
api_docs: 'API 문서',
api_keys_api_docs: '노드 URL, API 키와 API 문서',
api_keys_warning:
'이 키는 안전하게 보관해야 하며, 공유하면 자금을 잃을 위험이 있습니다.',
admin_key_warning:
'관리자 키는 결제 전송을 포함하여 지갑에 대한 모든 권한을 부여합니다. 수신자를 완전히 신뢰하는 경우가 아니라면 절대 공유하지 마세요.',
lnbits_version: 'LNbits 버전',
runs_on: 'Runs on',
paste: '붙여넣기',
@ -435,5 +439,47 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항',
http_request_details: 'HTTP 요청 세부사항'
http_request_details: 'HTTP 요청 세부사항',
block_explorer: '블록 탐색기',
enable_block_explorer: '블록 탐색기 활성화',
block_explorer_desc:
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
blockexplorer_public_api: '공개 API 접근',
blockexplorer_public_api_desc:
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
electrum_server_url: 'Electrum 서버 URL',
electrum_server_url_hint:
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
blockexplorer_search_label: 'TXID 또는 주소로 검색',
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
recent_blocks: '최근 블록',
chain_tip: '체인 끝',
block_height: '블록 높이',
block_fee: '블록 수수료',
fee_estimates: '수수료 추정',
confirmed_balance: '확인된 잔액',
unconfirmed_balance: '미확인 잔액',
transaction_history: '거래 내역',
coinbase: 'Coinbase',
inputs: '입력',
outputs: '출력',
confirmations: '확인 수',
confirmed: '확인됨',
unconfirmed: '미확인',
history_unavailable:
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
address: '주소',
block_number: '블록 #{height}',
block_diff: 'diff {value}',
block_hash: '해시',
previous_block: '이전 블록',
merkle_root: '머클 루트',
version: '버전',
bits: 'Bits',
difficulty: '난이도',
nonce: 'Nonce',
txid: 'TXID',
vsize: '가상 크기',
weight: '무게',
n_block_fee: '{n}블록 수수료'
}

View file

@ -73,6 +73,10 @@ window.localisation.nl = {
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
api_docs: 'API-documentatie',
api_keys_api_docs: 'Node URL, API-sleutels en API-documentatie',
api_keys_warning:
'Bewaar deze sleutels veilig; het delen ervan kan leiden tot verlies van tegoeden.',
admin_key_warning:
'Je beheerderssleutel geeft volledige toegang tot je wallet, inclusief de mogelijkheid om betalingen te versturen. Deel deze nooit, tenzij je de ontvanger volledig vertrouwt.',
lnbits_version: 'LNbits-versie',
runs_on: 'Draait op',
paste: 'Plakken',
@ -450,5 +454,48 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails',
http_request_details: 'HTTP-verzoekdetails'
http_request_details: 'HTTP-verzoekdetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer inschakelen',
block_explorer_desc:
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
blockexplorer_public_api: 'Publieke API-toegang',
blockexplorer_public_api_desc:
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
electrum_server_url: 'Electrum-server-URL',
electrum_server_url_hint:
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
blockexplorer_search_label: 'Zoeken op TXID of adres',
blockexplorer_search_hint:
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
recent_blocks: 'Recente blokken',
chain_tip: 'Kettingtop',
block_height: 'Blokhoogte',
block_fee: 'blokvergoeding',
fee_estimates: 'Vergoedingsschattingen',
confirmed_balance: 'Bevestigd saldo',
unconfirmed_balance: 'Onbevestigd saldo',
transaction_history: 'Transactiegeschiedenis',
coinbase: 'Coinbase',
inputs: 'Invoer',
outputs: 'Uitvoer',
confirmations: 'Bevestigingen',
confirmed: 'Bevestigd',
unconfirmed: 'Onbevestigd',
history_unavailable:
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'moeil. {value}',
block_hash: 'Hash',
previous_block: 'Vorig blok',
merkle_root: 'Merkle-wortel',
version: 'Versie',
bits: 'Bits',
difficulty: 'Moeilijkheid',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuele grootte',
weight: 'Gewicht',
n_block_fee: '{n}-blok vergoeding'
}

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