Compare commits

..
Author SHA1 Message Date
Vlad Stan 4744ef9bba Merge branch 'more_unit_tests' of https://github.com/lnbits/lnbits into more_unit_tests 2026-03-30 16:00:12 +03:00
Vlad Stan fdf061c08d chore: make regtest wait 2026-03-30 15:32:42 +03:00
Vlad Stan d348234cad fix: aaaaa 2026-03-30 15:32:42 +03:00
Vlad Stan fc5261132e fix: regtests 2026-03-30 15:32:42 +03:00
Vlad Stan 4e18c423de chore: check log 2026-03-30 15:32:42 +03:00
Vlad Stan 37e5601207 fix: try clean-up 2026-03-30 15:32:42 +03:00
Vlad Stan 231f6fc01c fix: is boltz wallet 2026-03-30 15:32:42 +03:00
Vlad Stan 811957f5e1 chore: logs 2026-03-30 15:32:42 +03:00
Vlad Stan c63a53cb14 chore: some logs 2026-03-30 15:32:42 +03:00
Vlad Stan 78b8f7f16e fix: hope for regtest to work 2026-03-30 15:32:42 +03:00
Vlad Stan 4642878c05 fix: test 2026-03-30 15:32:42 +03:00
Vlad Stan d7c850ccbb fix: test funding source 2026-03-30 15:32:42 +03:00
Vlad Stan 4fbb4b5eef chore: fix lint 2026-03-30 15:32:42 +03:00
Vlad Stan db2829a5f6 chore: postpone test 2026-03-30 15:31:59 +03:00
Vlad Stan 13ce1ac172 chore: fix lint 2026-03-30 15:08:42 +03:00
Vlad Stan 512697ae53 test: more tests 2026-03-30 14:58:04 +03:00
Vlad Stan dfa89ca063 test: some tests 2026-03-30 13:18:36 +03:00
Vlad Stan 182694d2e5 fix: bad config 2026-03-30 13:12:47 +03:00
Vlad Stan 2f10c57a10 fix: timeout 2026-03-30 13:12:46 +03:00
Vlad Stan d685824596 fix: test funding source 2026-03-30 13:12:46 +03:00
Vlad Stan e816eb2dac fix: date 2026-03-30 13:12:46 +03:00
Vlad Stan d3c099350a chore: fix lint 2026-03-30 13:12:46 +03:00
Vlad Stan 691ac969dc chore: postpone test 2026-03-30 13:12:46 +03:00
Vlad Stan 85e8ac6057 refactor: extract helpers, move private functions to the bottom 2026-03-30 13:12:46 +03:00
Vlad Stan 99da300f6d chore: code lint 2026-03-30 13:12:46 +03:00
Vlad Stan cd413d85ed fix: tests 2026-03-30 13:12:46 +03:00
Vlad Stan 9245e2c268 fix: lint 2026-03-30 13:12:46 +03:00
Vlad Stan 0b5c8cdee2 test: more api tests 2026-03-30 13:12:46 +03:00
Vlad Stan c94cef07c2 chore: fix lint 2026-03-30 13:12:46 +03:00
Vlad Stan 22b4ab779f test: more tests 2026-03-30 13:12:46 +03:00
Vlad Stan 42061d0cc8 test: some tests 2026-03-30 13:12:46 +03:00
Vlad Stan d0ea42fd70 chore: make regtest wait 2026-03-26 12:28:12 +02:00
Vlad Stan cd58a0dc9b fix: aaaaa 2026-03-26 12:12:13 +02:00
Vlad Stan cac4e309f4 fix: regtests 2026-03-26 11:57:25 +02:00
Vlad Stan 632cd257d7 chore: check log 2026-03-26 11:50:30 +02:00
Vlad Stan a11274593c fix: try clean-up 2026-03-26 11:49:57 +02:00
Vlad Stan ae92965b69 fix: is boltz wallet 2026-03-26 11:45:16 +02:00
Vlad Stan 3e1f75c4cc chore: logs 2026-03-26 11:32:02 +02:00
Vlad Stan d668747ca2 chore: some logs 2026-03-26 11:24:18 +02:00
Vlad Stan dedfcd4818 fix: hope for regtest to work 2026-03-25 11:37:21 +02:00
Vlad Stan f66f2fe6c2 fix: test 2026-03-25 11:24:25 +02:00
Vlad Stan f3ca37e846 Merge branch 'more_unit_tests' of https://github.com/lnbits/lnbits into more_unit_tests 2026-03-25 10:54:23 +02:00
Vlad Stan d45709a80f fix: bad config 2026-03-25 10:53:25 +02:00
Vlad Stan e4b6dc84b5 fix: timeout 2026-03-25 10:49:52 +02:00
Vlad Stan 67d1b4be3e fix: test funding source 2026-03-25 10:49:52 +02:00
Vlad Stan bd514f1a46 fix: date 2026-03-25 10:49:52 +02:00
Vlad Stan 03b302da69 chore: fix lint 2026-03-25 10:49:52 +02:00
Vlad Stan b373e35915 chore: postpone test 2026-03-25 10:49:52 +02:00
Vlad Stan 0fd116e7ee refactor: extract helpers, move private functions to the bottom 2026-03-25 10:49:52 +02:00
Vlad Stan 2c84f9781d chore: code lint 2026-03-25 10:49:52 +02:00
Vlad Stan 9fcf3121ac fix: tests 2026-03-25 10:49:52 +02:00
Vlad Stan 1df9fa831d fix: lint 2026-03-25 10:49:52 +02:00
Vlad Stan 42039070b9 test: more api tests 2026-03-25 10:49:52 +02:00
Vlad Stan 2f9146fff3 chore: fix lint 2026-03-25 10:49:52 +02:00
Vlad Stan 5738795c8c test: more tests 2026-03-25 10:49:52 +02:00
Vlad Stan 3c77acf266 test: some tests 2026-03-25 10:49:52 +02:00
Vlad Stan a4f2f55279 fix: timeout 2026-03-23 14:01:55 +02:00
Vlad Stan 2ab7c17ceb fix: test funding source 2026-03-23 12:33:57 +02:00
Vlad Stan 7a2c509cd3 fix: date 2026-03-23 12:32:39 +02:00
Vlad Stan 8a51ea53c8 chore: fix lint 2026-03-23 12:32:39 +02:00
Vlad Stan 19d3e9e64e chore: postpone test 2026-03-23 12:32:39 +02:00
Vlad Stan 9f9f14ead0 refactor: extract helpers, move private functions to the bottom 2026-03-23 12:32:39 +02:00
Vlad Stan 0a093d82ae chore: code lint 2026-03-23 12:32:39 +02:00
Vlad Stan 17fd459fa7 fix: tests 2026-03-23 12:32:39 +02:00
Vlad Stan fe9aa4d8c9 fix: lint 2026-03-23 12:32:39 +02:00
Vlad Stan 1af3b62739 test: more api tests 2026-03-23 12:32:39 +02:00
Vlad Stan e9cbc3492f chore: fix lint 2026-03-23 12:32:39 +02:00
Vlad Stan ca2f78d25b test: more tests 2026-03-23 12:32:39 +02:00
Vlad Stan 9d9ab48345 test: some tests 2026-03-23 12:32:39 +02:00
184 changed files with 27732 additions and 17881 deletions
+1 -13
View File
@@ -7,20 +7,14 @@
# They are NOT managed by the Admin UI and are not stored in the database. # They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token === # === First Install Token ===
# If set the user is required to enter this token on the /first_install page # if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken" # FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development === # === Logging and Development ===
DEBUG=False DEBUG=False
DEBUG_DATABASE=False DEBUG_DATABASE=False
BUNDLE_ASSETS=True BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/ # logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true ENABLE_LOG_TO_FILE=true
@@ -30,10 +24,6 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings === # === Admin Settings ===
@@ -119,8 +109,6 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack." LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
###################################### ######################################
+14 -4
View File
@@ -7,6 +7,10 @@ on:
description: 'The tag name for the release' description: 'The tag name for the release'
required: true required: true
type: string type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -14,6 +18,10 @@ on:
description: 'The tag name for the release' description: 'The tag name for the release'
required: true required: true
type: string type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs: jobs:
build-linux-package: build-linux-package:
@@ -61,10 +69,7 @@ jobs:
--onefile \ --onefile \
--name lnbits \ --name lnbits \
--hidden-import=embit \ --hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \ --collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \ --collect-all lnbits \
--collect-all sqlalchemy \ --collect-all sqlalchemy \
--collect-all breez_sdk \ --collect-all breez_sdk \
@@ -105,6 +110,11 @@ jobs:
shell: bash shell: bash
- name: Upload Linux Release Asset - 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
+3 -7
View File
@@ -10,16 +10,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }} ref: ${{ github.head_ref }}
- uses: lnbits/lnbits/.github/actions/prepare@dev - uses: lnbits/lnbits/.github/actions/prepare@dev
with: with:
python-version: "3.10" python-version: "3.10"
node-version: "24.x" node-version: "24.x"
npm: true npm: true
- name: Build and commit bundle (same-repo PR) - run: make bundle
if: github.event.pull_request.head.repo.full_name == github.repository - name: Commit and push bundle changes
run: | run: |
make bundle
git config user.name "alan" git config user.name "alan"
git config user.email "alan@lnbits.com" git config user.email "alan@lnbits.com"
git add lnbits/static git add lnbits/static
@@ -28,6 +27,3 @@ jobs:
fi fi
git commit -m "chore: make bundle [skip ci]" git commit -m "chore: make bundle [skip ci]"
git push git push
- name: Check bundle is up-to-date (fork PR)
if: github.event.pull_request.head.repo.full_name != github.repository
run: make checkbundle
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
make: openapi make: openapi
regtest: regtest:
needs: [ lint ] needs: [ lint, test-api, test-wallets, test-unit, migration, openapi ]
uses: ./.github/workflows/regtest.yml uses: ./.github/workflows/regtest.yml
strategy: strategy:
matrix: matrix:
-1
View File
@@ -66,7 +66,6 @@ jobs:
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
DEBUG: true DEBUG: true
+11
View File
@@ -12,6 +12,8 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github pre-release - name: Create github pre-release
@@ -20,6 +22,14 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --prerelease --generate-notes --draft gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
@@ -64,3 +74,4 @@ jobs:
uses: ./.github/workflows/appimage.yml uses: ./.github/workflows/appimage.yml
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+11
View File
@@ -13,6 +13,8 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@@ -21,6 +23,14 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft 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: docker:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
@@ -75,3 +85,4 @@ jobs:
uses: ./.github/workflows/appimage.yml uses: ./.github/workflows/appimage.yml
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-1
View File
@@ -6,7 +6,6 @@ __pycache__
*$py.class *$py.class
.mypy_cache .mypy_cache
.vscode .vscode
.codex
*-lock.json *-lock.json
.python-version .python-version
-1
View File
@@ -1 +0,0 @@
min-release-age=7
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px"> <img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img alt="lnbits_head" src="docs/assets/header.jpg" /> <img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source. LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" /> <img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe ## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions. All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" /> <img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API ## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services. LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" /> <img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager ## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier. LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" /> <img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools ## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business. As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" /> <img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits ## Powered by LNbits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI # LNBits Admin UI
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table # Backend Wallet Comparison Table
+6 -3
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation # Basic installation
@@ -51,7 +51,7 @@ nav_order: 1
sudo apt-get install jq libfuse2 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 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 chmod +x LNbits-latest.AppImage
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_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 will create a folder for DB and extension files **in the same directory** as the AppImage. - LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
@@ -285,7 +285,10 @@ but you can also set the env variables or pass command line arguments:
```sh ```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI. # .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0 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
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU) # LNbits Super User (SU)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview # LNbits Roles: A Quick Overview
+1
View File
@@ -14,6 +14,7 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets # Backend wallets
+33 -58
View File
@@ -23,35 +23,29 @@ from lnbits.core.crud import (
get_installed_extensions, get_installed_extensions,
update_installed_extension_state, 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.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.funding_source import ( from lnbits.core.services.notifications import enqueue_admin_notification
check_balance_delta_changed, from lnbits.core.services.payments import check_pending_payments
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 ( from lnbits.core.tasks import (
audit_queue, audit_queue,
collect_exchange_rates_data, collect_exchange_rates_data,
notify_server_status, purge_audit_data,
process_next_audit_entry, run_by_the_minute_tasks,
refresh_extension_cache, wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
) )
from lnbits.exceptions import register_exception_handlers from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings 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.cache import cache
from lnbits.utils.logger import ( from lnbits.utils.logger import (
configure_logger, configure_logger,
@@ -71,10 +65,9 @@ from .middleware import (
InstalledExtensionMiddleware, InstalledExtensionMiddleware,
add_first_install_middleware, add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .task_manager import task_manager from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI): async def startup(app: FastAPI):
@@ -108,9 +101,6 @@ async def startup(app: FastAPI):
# register core routes # register core routes
init_core_routers(app) init_core_routers(app)
# register llms.txt endpoint for AI agents
create_llms_txt_route(app)
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks()
@@ -138,7 +128,7 @@ async def shutdown():
settings.lnbits_running = False settings.lnbits_running = False
# shutdown event # shutdown event
task_manager.cancel_all_tasks() cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems # wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -206,9 +196,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app return app
@@ -477,42 +464,30 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None: def register_async_tasks() -> None:
task_manager.init()
# listen to all incoming payments and dispatch payment notifications create_permanent_task(wait_for_audit_data)
# note: should be the first in task list for a bit quicker notifications create_permanent_task(wait_notification_messages)
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
# periodic tasks create_permanent_task(
task_manager.create_permanent_task(cache.invalidate_cache, interval=10) run_interval(
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60) settings.lnbits_funding_source_pending_interval_seconds,
task_manager.create_permanent_task( check_pending_payments,
check_pending_payments, )
interval=settings.lnbits_funding_source_pending_interval_seconds,
) )
task_manager.create_permanent_task( create_permanent_task(invoice_listener)
collect_exchange_rates_data, create_permanent_task(internal_invoice_listener)
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds), create_permanent_task(cache.invalidate_forever)
)
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)
# permanent tasks run in a loop, will be restarted if they fail # core invoice listener
task_manager.create_permanent_task(fundingsource_invoice_producer) invoice_queue: asyncio.Queue = asyncio.Queue()
task_manager.create_permanent_task(process_next_notification) register_invoice_listener(invoice_queue, "core")
task_manager.create_permanent_task(process_next_audit_entry) create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# server logs for websocket # server logs for websocket
if settings.lnbits_admin_ui: if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger() server_log_task = initialize_server_websocket_logger()
task_manager.create_permanent_task( create_permanent_task(server_log_task)
server_log_task, name="server_websocket_logger"
)
-2
View File
@@ -6,7 +6,6 @@ from .views.api import api_router
from .views.asset_api import asset_router from .views.asset_api import asset_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.blockexplorer_api import blockexplorer_router
from .views.callback_api import callback_router from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router from .views.extensions_builder_api import extension_builder_router
@@ -49,7 +48,6 @@ def init_core_routers(app: FastAPI):
app.include_router(asset_router) app.include_router(asset_router)
app.include_router(fiat_router) app.include_router(fiat_router)
app.include_router(lnurl_router) app.include_router(lnurl_router)
app.include_router(blockexplorer_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]
-4
View File
@@ -12,7 +12,6 @@ from .extensions import (
drop_extension_db, drop_extension_db,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids, get_user_active_extensions_ids,
get_user_extension, get_user_extension,
get_user_extensions, get_user_extensions,
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username, get_account_by_username,
get_account_by_username_or_email, get_account_by_username_or_email,
get_accounts, get_accounts,
get_accounts_count,
get_user, get_user,
get_user_access_control_lists, get_user_access_control_lists,
get_user_from_account, get_user_from_account,
@@ -119,13 +117,11 @@ __all__ = [
"get_account_by_username", "get_account_by_username",
"get_account_by_username_or_email", "get_account_by_username_or_email",
"get_accounts", "get_accounts",
"get_accounts_count",
"get_admin_settings", "get_admin_settings",
"get_db_version", "get_db_version",
"get_db_versions", "get_db_versions",
"get_installed_extension", "get_installed_extension",
"get_installed_extensions", "get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension", "get_latest_payments_by_extension",
"get_payment", "get_payment",
"get_payments", "get_payments",
-7
View File
@@ -90,13 +90,6 @@ async def get_installed_extensions(
return all_extensions return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension( async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None: ) -> UserExtension | None:
+5 -6
View File
@@ -290,10 +290,8 @@ async def create_payment(
webhook=data.webhook, webhook=data.webhook,
fee=-abs(data.fee), fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extension=data.extension,
extra=extra, extra=extra,
labels=data.labels or [], labels=data.labels or [],
external_id=data.external_id,
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
@@ -307,7 +305,7 @@ async def update_payment_checking_id(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
UPDATE apipayments UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")} SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id WHERE checking_id = :old_id
""", # noqa: S608 """, # noqa: S608
{ {
@@ -322,15 +320,13 @@ async def update_payment(
payment: Payment, payment: Payment,
new_checking_id: str | None = None, new_checking_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> None:
payment.updated_at = datetime.now(timezone.utc) payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
) )
if new_checking_id and new_checking_id != payment.checking_id: if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn) await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history( async def get_payments_history(
@@ -402,6 +398,7 @@ async def get_payment_count_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentCountStat]: ) -> list[PaymentCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
extra_stmts = [] extra_stmts = []
@@ -434,6 +431,7 @@ async def get_daily_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -483,6 +481,7 @@ async def get_wallets_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentWalletStats]: ) -> list[PaymentWalletStats]:
if not filters: if not filters:
filters = Filters() filters = Filters()
+10 -15
View File
@@ -8,18 +8,11 @@ from lnbits.db import dict_to_model
from lnbits.settings import ( from lnbits.settings import (
AdminSettings, AdminSettings,
EditableSettings, EditableSettings,
FundingSourcesSettings,
SettingsField, SettingsField,
SuperSettings, SuperSettings,
settings, settings,
) )
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None: async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core") data = await get_settings_by_tag("core")
@@ -76,14 +69,16 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None: async def reset_core_settings() -> None:
core_settings = await get_settings_by_tag("core") or {} await db.execute(
super_user = await get_settings_field("super_user") """
await delete_admin_settings() DELETE FROM system_settings WHERE tag = 'core'
if super_user: AND id NOT IN (
await set_settings_field("super_user", super_user.value) 'super_user',
for field in RESET_PRESERVED_SETTINGS: 'lnbits_webpush_pubkey',
if field in core_settings: 'lnbits_webpush_privkey'
await set_settings_field(field, core_settings[field]) )
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings: async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
-7
View File
@@ -37,13 +37,6 @@ async def create_account(
return account return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await (conn or db).update("accounts", account)
-13
View File
@@ -802,16 +802,3 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status. Used for account activation status.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true") await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
-2
View File
@@ -25,7 +25,6 @@ from .payments import (
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
UpdatePaymentExtra,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -91,7 +90,6 @@ __all__ = [
"SimpleStatus", "SimpleStatus",
"TinyURL", "TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
+4 -18
View File
@@ -6,6 +6,7 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -20,7 +21,6 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
@@ -55,10 +55,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel): class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = [] extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = [] repos: list[GitHubRelease] = []
featured: list[str] = []
categories: dict[str, list[str]] = {}
class GitHubRepoRelease(BaseModel): class GitHubRepoRelease(BaseModel):
@@ -309,6 +308,7 @@ class ExtensionRelease(BaseModel):
@classmethod @classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None: async def fetch_release_details(cls, details_link: str) -> dict | None:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(details_link) resp = await client.get(details_link)
@@ -333,7 +333,6 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = [] dependencies: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
categories: list[str] = []
paid_features: str | None = None paid_features: str | None = None
has_paid_release: bool = False has_paid_release: bool = False
has_free_release: bool = False has_free_release: bool = False
@@ -642,10 +641,7 @@ class InstallableExtension(BaseModel):
if cache_value.older_than(10 * 60) or post_refresh_cache: if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested # refresh cache in background if older than 10 minutes or requested
task_manager.create_task( create_task(cls._refresh_installable_extensions_cache())
cls._refresh_installable_extensions_cache(),
"refresh_installable_extensions_cache",
)
extension_list = cache_value.value # type: ignore extension_list = cache_value.value # type: ignore
return extension_list return extension_list
@@ -684,11 +680,6 @@ class InstallableExtension(BaseModel):
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
@@ -704,11 +695,6 @@ class InstallableExtension(BaseModel):
ext.check_release_updates(release) ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
except Exception as e: except Exception as e:
+8 -39
View File
@@ -13,7 +13,6 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentStatus, FiatPaymentStatus,
) )
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentStatus, PaymentStatus,
@@ -35,11 +34,6 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel): class PayInvoice(BaseModel):
payment_request: str payment_request: str
description: str | None = None description: str | None = None
@@ -54,17 +48,11 @@ class CreatePayment(BaseModel):
amount_msat: int amount_msat: int
memo: str memo: str
extra: dict | None = {} extra: dict | None = {}
extension: str | None = None
preimage: str | None = None preimage: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel): class Payment(BaseModel):
@@ -89,11 +77,6 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -141,18 +124,22 @@ class Payment(BaseModel):
) )
# DEPRECATED: in v1.5.0, use service check_payment_status instead # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(self) -> PaymentStatus: async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.") logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self) return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(self) -> FiatPaymentStatus: async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.") logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status from lnbits.core.services.fiat_providers import check_fiat_status
return await check_fiat_status(self) return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
@@ -164,7 +151,6 @@ class PaymentFilters(FilterModel):
"status", "status",
"time", "time",
"labels", "labels",
"external_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
@@ -175,13 +161,11 @@ class PaymentFilters(FilterModel):
"memo", "memo",
"time", "time",
"tag", "tag",
"external_id",
] ]
status: str | None status: str | None
tag: str | None tag: str | None
checking_id: str | None checking_id: str | None
external_id: str | None
amount: int amount: int
fee: int fee: int
memo: str | None memo: str | None
@@ -260,13 +244,11 @@ class CreateInvoice(BaseModel):
) )
expiry: int | None = None expiry: int | None = None
extra: dict | None = None extra: dict | None = None
extension: str | None = None
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@@ -281,10 +263,6 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported") raise ValueError("The provided unit is not supported")
return v return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel): class PaymentsStatusCount(BaseModel):
incoming: int = 0 incoming: int = 0
@@ -323,12 +301,3 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel): class UpdatePaymentLabels(BaseModel):
labels: list[str] = [] labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
-12
View File
@@ -1,10 +1,3 @@
from .blockexplorer import (
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
)
from .fiat_providers import check_fiat_status from .fiat_providers import check_fiat_status
from .funding_source import ( from .funding_source import (
get_balance_delta, get_balance_delta,
@@ -63,12 +56,7 @@ __all__ = [
"enqueue_admin_notification", "enqueue_admin_notification",
"fee_reserve", "fee_reserve",
"fee_reserve_total", "fee_reserve_total",
"fetch_fee_estimates",
"fetch_lnurl_pay_request", "fetch_lnurl_pay_request",
"fetch_onchain_balance",
"fetch_recent_blocks",
"fetch_tip",
"fetch_transaction",
"get_balance_delta", "get_balance_delta",
"get_payments_daily_stats", "get_payments_daily_stats",
"get_pr_from_lnurl", "get_pr_from_lnurl",
+3 -127
View File
@@ -1,9 +1,7 @@
import base64 import base64
import io import io
from urllib.parse import quote
from uuid import uuid4 from uuid import uuid4
import filetype
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
@@ -12,48 +10,11 @@ from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset from lnbits.core.models.assets import Asset
from lnbits.settings import settings from lnbits.settings import settings
IMAGE_MIME_TYPE_ALIASES = {
"heic": "image/heic",
"heics": "image/heics",
"heif": "image/heif",
"image/jpg": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
}
PIL_IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
}
INLINE_ASSET_MIME_TYPES = {
"image/heic",
"image/heics",
"image/heif",
"image/jpeg",
"image/png",
}
ASSET_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": (
"sandbox; default-src 'none'; script-src 'none'; "
"object-src 'none'; base-uri 'none'"
),
}
THUMBNAIL_FORMAT_MIME_TYPES = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
}
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset: async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type: if not file.content_type:
raise ValueError("File must have a content type.") raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
raise ValueError(f"File type '{file.content_type}' not allowed.") raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id): if not settings.is_unlimited_assets_user(user_id):
@@ -69,26 +30,14 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded." f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
) )
stored_mime_type = detect_image_mime_type(contents)
if stored_mime_type != content_type:
logger.warning(
"Image MIME type mismatch: declared={}, detected={}",
content_type,
stored_mime_type,
)
raise ValueError(
"Image file content does not match declared file type. "
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
)
thumb_buffer = thumbnail_from_bytes(contents) thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset( asset = Asset(
id=uuid4().hex, id=uuid4().hex,
user_id=user_id, user_id=user_id,
mime_type=stored_mime_type, mime_type=file.content_type,
is_public=is_public, is_public=is_public,
name=filename, name=file.filename or "unnamed",
size_bytes=len(contents), size_bytes=len(contents),
thumbnail_base64=( thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8") base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -102,79 +51,6 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
return asset return asset
def normalize_asset_mime_type(content_type: str) -> str:
content_type = content_type.split(";", 1)[0].strip().lower()
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
def normalize_media_type(media_type: str) -> str:
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
def thumbnail_media_type() -> str:
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
def content_disposition(disposition: str, filename: str) -> str:
safe_filename = filename or "unnamed"
quoted_filename = quote(safe_filename, safe="")
if quoted_filename == safe_filename:
return f'{disposition}; filename="{safe_filename}"'
return f"{disposition}; filename*=utf-8''{quoted_filename}"
def allowed_asset_mime_types() -> set[str]:
return {
mime_type
for mime_type in (
normalize_asset_mime_type(mime_type)
for mime_type in settings.lnbits_assets_allowed_mime_types
)
if mime_type.startswith("image/")
}
def detect_image_mime_type(contents: bytes) -> str:
kind = filetype.guess(contents)
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
verify_pil_image(contents, mime_type)
return mime_type
if mime_type and mime_type.startswith("image/"):
return mime_type
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if not mime_type:
raise ValueError("Image file content does not match declared file type.")
return mime_type
def verify_pil_image(contents: bytes, mime_type: str) -> None:
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if detected_mime_type != mime_type:
raise ValueError("Image file content does not match declared file type.")
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None: def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try: try:
image = Image.open(io.BytesIO(contents)) image = Image.open(io.BytesIO(contents))
-97
View File
@@ -1,97 +0,0 @@
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,
)
-12
View File
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
delete_installed_extension, delete_installed_extension,
get_db_version, get_db_version,
get_installed_extension, get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.crud.extensions import ( from lnbits.core.crud.extensions import (
@@ -39,8 +38,6 @@ async def install_extension(
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download: if not skip_download:
await ext_info.download_archive() await ext_info.download_archive()
@@ -66,15 +63,6 @@ async def install_extension(
return extension return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str): async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id) await stop_extension_background_work(ext_id)
+11 -84
View File
@@ -2,13 +2,12 @@ import hashlib
import hmac import hmac
import json import json
import time import time
from base64 import b64encode
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, update_payment from lnbits.core.crud.payments import create_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection from lnbits.db import Connection
@@ -20,7 +19,6 @@ from lnbits.fiat.base import (
FiatPaymentSuccessStatus, FiatPaymentSuccessStatus,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.task_manager import task_manager
async def handle_fiat_payment_confirmation( async def handle_fiat_payment_confirmation(
@@ -37,7 +35,9 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus: async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal: if not payment.is_internal:
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
if payment.success: if payment.success:
@@ -57,11 +57,14 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id) fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success: if fiat_status.success:
payment.status = PaymentState.SUCCESS.value # notify receivers asynchronously
await update_payment(payment) from lnbits.tasks import internal_invoice_queue
await handle_fiat_payment_confirmation(payment)
task_manager.internal_invoice_queue.put_nowait(payment) await internal_invoice_queue.put(payment.checking_id)
return fiat_status return fiat_status
@@ -166,82 +169,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
raise ValueError("PayPal webhook cannot be verified.") from exc raise ValueError("PayPal webhook cannot be verified.") from exc
def check_square_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
notification_url: str | None,
):
if not sig_header:
logger.warning("Square signature header is missing.")
raise ValueError("Square signature header is missing.")
if not secret:
logger.warning("Square webhook signature key is not set.")
raise ValueError("Square webhook cannot be verified.")
if not notification_url:
logger.warning("Square webhook notification URL is not set.")
raise ValueError("Square webhook cannot be verified.")
signed_payload = notification_url.encode() + payload
computed_signature = b64encode(
hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).digest()
).decode()
if hmac.compare_digest(computed_signature, sig_header) is not True:
logger.warning("Square signature verification failed.")
raise ValueError("Square signature verification failed.")
def check_revolut_signature(
payload: bytes,
sig_header: str | None,
timestamp_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Revolut signature header is missing.")
raise ValueError("Revolut signature header is missing.")
if not timestamp_header:
logger.warning("Revolut timestamp header is missing.")
raise ValueError("Revolut timestamp header is missing.")
if not secret:
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
digest = hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"v1={digest}"
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.
-2
View File
@@ -66,8 +66,6 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed(): async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta() status = await get_balance_delta()
if settings.latest_balance_delta_sats is None: if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
+6 -44
View File
@@ -74,7 +74,7 @@ async def send_admin_notification(
message: str, message: str,
message_type: str | None = None, message_type: str | None = None,
) -> None: ) -> None:
return await send_notification_in_background( return await send_notification(
settings.lnbits_telegram_notifications_chat_id, settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers, settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails, settings.lnbits_email_notifications_to_emails,
@@ -97,7 +97,7 @@ async def send_user_notification(
if user_notifications.nostr_identifier if user_notifications.nostr_identifier
else [] else []
) )
return await send_notification_in_background( return await send_notification(
user_notifications.telegram_chat_id, user_notifications.telegram_chat_id,
nostr_identifiers, nostr_identifiers,
email_address, email_address,
@@ -222,29 +222,12 @@ async def send_email(
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(message, "plain")) msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
def _send() -> bool: smtp_server.starttls()
with smtplib.SMTP(server, port) as smtp_server: smtp_server.login(username, password)
smtp_server.starttls() smtp_server.sendmail(from_email, to_emails, msg.as_string())
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True return True
try:
return await asyncio.to_thread(_send)
except Exception as e:
logger.warning(f"Sending Email failed. {e!s}")
return False
async def dispatch_payment_notification(payment: Payment) -> None:
"""
This worker dispatches the payment notifications.
"""
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def dispatch_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
""" """
@@ -311,27 +294,6 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
logger.warning(f"Error sending payment notification: {e}") logger.warning(f"Error sending payment notification: {e}")
async def send_notification_in_background(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: str | None = None,
):
try:
create_task(
send_notification(
telegram_chat_id,
nostr_identifiers,
email_addresses,
message,
message_type,
)
)
except Exception as e:
logger.warning(f"Error sending notification in background: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send(wallet.inkey, payment.json())
+39 -59
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings 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.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.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@@ -64,7 +64,6 @@ async def pay_invoice(
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
@@ -98,7 +97,6 @@ async def pay_invoice(
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels, labels=labels,
external_id=external_id,
) )
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -171,15 +169,15 @@ async def create_fiat_invoice(
internal_payment.fiat_provider = fiat_provider_name internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# TODO: move to payment # todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = ( new_checking_id = (
f"fiat_{fiat_provider_name}_" f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}" f"{fiat_invoice.checking_id or internal_payment.checking_id}"
) )
internal_payment = await update_payment( await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment, new_checking_id, conn=conn internal_payment.checking_id = new_checking_id
)
return internal_payment return internal_payment
@@ -215,12 +213,10 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
expiry=data.expiry, expiry=data.expiry,
extra=data.extra, extra=data.extra,
extension=data.extension,
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
labels=data.labels, labels=data.labels,
external_id=data.external_id,
conn=conn, conn=conn,
) )
@@ -261,9 +257,7 @@ async def create_invoice(
webhook: str | None = None, webhook: str | None = None,
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
extension: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -345,11 +339,9 @@ async def create_invoice(
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
extension=extension,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels, labels=labels,
external_id=external_id,
) )
payment = await create_payment( payment = await create_payment(
@@ -377,7 +369,7 @@ async def update_pending_payment(
status = await check_payment_status(payment) status = await check_payment_status(payment)
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
payment = await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status, conn=conn)
return payment return payment
@@ -517,7 +509,9 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
task_manager.internal_invoice_queue.put_nowait(payment) from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
async def check_wallet_limits( async def check_wallet_limits(
@@ -631,14 +625,18 @@ async def check_transaction_status(
return await check_payment_status(payment) return await check_payment_status(payment)
async def check_payment_status(payment: Payment) -> PaymentStatus: async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal: if payment.is_internal:
if payment.success: if payment.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if payment.failed: if payment.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider: if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(payment) fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid) return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
@@ -780,16 +778,13 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background( await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
wallet.id, payment, conn=conn
) # notify the sender # notify receiver asynchronously
await _send_payment_notification_in_background( from lnbits.tasks import internal_invoice_queue
internal_payment.wallet_id, internal_payment, conn=conn
) # notify the receiver
# notify receiver asynchronously (extension listeners)
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}") logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
task_manager.internal_invoice_queue.put_nowait(internal_payment) await internal_invoice_queue.put(internal_payment.checking_id)
return payment return payment
@@ -826,15 +821,16 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
task = task_manager.create_task( from lnbits.tasks import create_task
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
f"fundingsource_pay_invoice_{checking_id}", task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
# make sure a hold invoice or deferred payment is not blocking the server # 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) wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try: try:
payment_response = await asyncio.wait_for(task.task, timeout=wait_time) payment_response = await asyncio.wait_for(task, timeout=wait_time)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# return pending payment on timeout # return pending payment on timeout
logger.debug( logger.debug(
@@ -875,7 +871,7 @@ async def update_payment_success_status(
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat)) payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage payment.preimage = payment.preimage or status.preimage
payment = await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
return payment return payment
@@ -1078,44 +1074,28 @@ async def _send_payment_notification_in_background(
send_payment_notification_in_background(wallet, payment) send_payment_notification_in_background(wallet, payment)
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None: async def update_invoice_callback(checking_id: str) -> Payment | None:
""" """
Takes a checking_id of an incoming payment from paid_invoices_stream() Takes a checking_id of an incoming payment, from either paid_invoices_stream()
Checks its status, updates its status and returns it. or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no incoming payment was found or the status is not successful returns None if no payment was found or it not and incoming payment.
""" """
payment = await get_standalone_payment(checking_id, incoming=True) payment = await get_standalone_payment(checking_id, incoming=True)
if not payment: if not payment:
logger.warning(f"No incoming payment found for '{checking_id}'.") logger.warning(f"No payment found for '{checking_id}'.")
return None return None
if not payment.is_in:
status = await check_payment_status(payment) logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
if not status.success:
logger.error(
"Unexpected status response from paid_invoices_stream. Skipping update."
)
return None return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it # only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment = await update_payment(payment) await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return 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)
-12
View File
@@ -23,7 +23,6 @@ from ..crud import (
get_account_by_email, get_account_by_email,
get_account_by_pubkey, get_account_by_pubkey,
get_account_by_username, get_account_by_username,
get_accounts_count,
get_super_settings, get_super_settings,
get_user_extensions, get_user_extensions,
get_user_from_account, get_user_from_account,
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None, conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account: if account:
account.validate_fields() account.validate_fields()
if account.username and await get_account_by_username( if account.username and await get_account_by_username(
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
return user return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account: async def update_user_account(account: Account) -> Account:
account.validate_fields() account.validate_fields()
+118 -47
View File
@@ -2,43 +2,76 @@ import asyncio
from loguru import logger from loguru import logger
from lnbits.core.crud import create_audit_entry from lnbits.core.crud import (
create_audit_entry,
get_wallet,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models.audit import AuditEntry from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import get_balance_delta from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
)
from lnbits.core.services.notifications import ( from lnbits.core.services.notifications import (
enqueue_admin_notification, enqueue_admin_notification,
process_next_notification,
send_payment_notification,
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.settings import settings 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() audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def process_next_audit_entry() -> None: async def run_by_the_minute_tasks() -> None:
""" minute_counter = 0
Waits for audit entries to be pushed to the queue. while settings.lnbits_running:
Then it inserts the entries into the DB. status_minutes = settings.lnbits_notification_server_status_hours * 60
"""
data = await audit_queue.get() if settings.notification_balance_delta_threshold_sats > 0:
await create_audit_entry(data) 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 refresh_extension_cache() -> None: async def _notify_server_status() -> 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)) accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count() wallets_count = await get_wallets_count()
payments = await get_payments_status_count() payments = await get_payments_status_count()
status = await get_balance_delta() status = await get_balance_delta()
values = { values = {
"up_time": settings.lnbits_server_up_time, "up_time": settings.lnbits_server_up_time,
@@ -55,38 +88,76 @@ async def notify_server_status() -> None:
enqueue_admin_notification(NotificationType.server_status, values) 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: async def collect_exchange_rates_data() -> None:
""" """
Collect exchange rates data. Used for monitoring only. Collect exchange rates data. Used for monitoring only.
""" """
currency = settings.lnbits_default_accounting_currency or "USD" while settings.lnbits_running:
max_history_size = settings.lnbits_exchange_history_size currency = settings.lnbits_default_accounting_currency or "USD"
try: max_history_size = settings.lnbits_exchange_history_size
if ( sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url if sleep_time > 0:
): try:
price = await btc_price_from_aggregator(currency) rates = await btc_rates(currency)
if price: if rates:
cache.set( rates_values = [r[1] for r in rates]
f"btc-price-{currency}", lnbits_rate = sum(rates_values) / len(rates_values)
price, rates.append(("LNbits", lnbits_rate))
expiry=settings.lnbits_exchange_rate_cache_seconds, settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
) except Exception as ex:
settings.append_exchange_rate_datapoint( logger.warning(ex)
{"Aggregator": price}, max_history_size
)
else: else:
rates = await btc_rates(currency) sleep_time = 60
if rates: await asyncio.sleep(sleep_time)
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)
+5 -4
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.decorators import check_admin, check_super_user
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.task_manager import PublicTask, task_manager from lnbits.tasks import invoice_listeners
from .. import core_app_extra from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,10 +44,11 @@ async def api_auditor():
name="Monitor", name="Monitor",
description="show the current listeners and other monitoring data", description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
) )
async def api_monitor() -> list[PublicTask]: async def api_monitor():
return task_manager.get_public_tasks() return {
"invoice_listeners": list(invoice_listeners.keys()),
}
@admin_router.get( @admin_router.get(
+9 -25
View File
@@ -16,14 +16,7 @@ from lnbits.core.crud.assets import (
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import ( from lnbits.core.services.assets import create_user_asset
ASSET_SECURITY_HEADERS,
INLINE_ASSET_MIME_TYPES,
content_disposition,
create_user_asset,
normalize_media_type,
thumbnail_media_type,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_account_id_exists,
@@ -82,7 +75,11 @@ async def api_get_asset_data(
if not asset: if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(asset.data, asset.mime_type, asset.name) return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get( @asset_router.get(
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
if not asset_info: if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response( return Response(
content=( content=(
base64.b64decode(asset_info.thumbnail_base64) base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64 if asset_info.thumbnail_base64
else b"" else b""
), ),
media_type=thumbnail_media_type(), media_type=asset_info.mime_type,
filename=asset_info.name, headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
) )
@@ -175,16 +172,3 @@ async def api_delete_asset(
await delete_user_asset(account_id.id, asset_id) await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.") return SimpleStatus(success=True, message="Asset deleted successfully.")
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
media_type = normalize_media_type(media_type)
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
return Response(
content=content,
media_type=media_type,
headers={
**ASSET_SECURITY_HEADERS,
"Content-Disposition": content_disposition(disposition, filename),
},
)
+2 -12
View File
@@ -36,7 +36,6 @@ from lnbits.decorators import (
check_account_exists, check_account_exists,
check_admin, check_admin,
check_user_exists, check_user_exists,
optional_user_id,
) )
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@@ -321,10 +320,7 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider( async def login_with_sso_provider(
request: Request, request: Request, provider: str, user_id: str | None = None
provider: str,
user_id: str | None,
auth_user_id: str | None = Depends(optional_user_id),
): ):
provider_sso = _new_sso(provider) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
@@ -332,8 +328,6 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.", f"Login by '{provider}' not allowed.",
) )
if user_id and user_id != auth_user_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token" provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso: with provider_sso:
@@ -354,11 +348,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
userinfo = await provider_sso.verify_and_process(request) userinfo = await provider_sso.verify_and_process(request)
if not userinfo: if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
if provider_sso.state is None or provider_sso.state == "null": user_id = decrypt_internal_message(provider_sso.state)
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None) request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id) return await _handle_sso_login(userinfo, user_id)
-170
View File
@@ -1,170 +0,0 @@
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)
+1 -428
View File
@@ -4,30 +4,17 @@ from fastapi import APIRouter, Request
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment, get_standalone_payment,
update_payment,
) )
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status, check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook, verify_paypal_webhook,
) )
from lnbits.core.services.payments import ( from lnbits.core.services.payments import create_fiat_invoice
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -63,41 +50,6 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "square":
payload = await request.body()
sig_header = request.headers.get("x-square-hmacsha256-signature")
check_square_signature(
payload,
sig_header,
settings.square_webhook_signature_key,
settings.square_payment_webhook_url,
)
event = await request.json()
await handle_square_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "revolut":
payload = await request.body()
sig_header = request.headers.get("Revolut-Signature")
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
check_revolut_signature(
payload,
sig_header,
timestamp_header,
settings.revolut_webhook_signing_secret,
)
event = await request.json()
await handle_revolut_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@@ -328,382 +280,3 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e: except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}") logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions() return FiatSubscriptionPaymentOptions()
async def handle_square_event(event: dict):
event_id = event.get("event_id") or event.get("id", "")
event_type = event.get("type", "")
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
if event_type == "payment.updated":
await _handle_square_payment_event(event)
return
if event_type == "invoice.payment_made":
await _handle_square_invoice_payment_made(event)
return
logger.warning(f"Unhandled Square event type: '{event_type}'.")
async def handle_revolut_event(event: dict):
event_type = event.get("event", "")
order_id = event.get("order_id")
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
if not order_id:
logger.warning("Revolut event missing order_id.")
return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if payment:
await check_fiat_status(payment)
return
if event_type == "ORDER_COMPLETED":
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
return
if event_type == "SUBSCRIPTION_INITIATED":
logger.info("Revolut subscription initiated event received.")
return
if event_type in [
"SUBSCRIPTION_CANCELLED",
"SUBSCRIPTION_FINISHED",
"SUBSCRIPTION_OVERDUE",
]:
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
return
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict,
fiat_provider: RevolutWallet,
order_id: str | None = None,
order: dict | None = None,
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return
reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference:
logger.warning("Revolut subscription event missing LNbits metadata.")
return
if not order_id:
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment:
if existing_payment.external_id != subscription_id:
existing_payment.external_id = subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
if not order:
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency:
raise ValueError("Revolut subscription order missing amount or currency.")
extra = {
**(reference.extra or {}),
"subscription_request_id": subscription_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
"checking_id": f"order_{order_id}",
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
amount_minor=amount_minor,
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
order_type = (order.get("type") or "").lower()
order_state = (order.get("state") or "").upper()
if order_type != "payment" or order_state != "COMPLETED":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription(
subscription, fiat_provider, order_id=order_id, order=order
)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency,
amount=amount,
memo=memo,
extra=extra,
internal=True,
external_id=subscription_id,
),
)
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut"
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict):
payment = _square_extract_payment(event)
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if payment_options.wallet_id:
if not _square_payment_is_completed(payment):
logger.debug("Square subscription payment is not completed yet.")
return
await _handle_square_subscription_payment(payment, payment_options)
return
order_id = payment.get("order_id")
if not order_id:
logger.warning("Square payment event missing order_id.")
return
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
if not lnbits_payment:
logger.warning(f"No payment found for Square order: '{order_id}'.")
return
await check_fiat_status(lnbits_payment)
async def _handle_square_invoice_payment_made(event: dict):
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
order_id = invoice.get("order_id")
if not order_id:
logger.warning("Square invoice.payment_made event missing order_id.")
return
subscription_id = invoice.get("subscription_id")
fiat_provider = await get_fiat_provider("square")
if not isinstance(fiat_provider, SquareWallet):
logger.warning("Square fiat provider is not configured.")
return
payment = await fiat_provider.get_payment_for_order(order_id)
if not payment:
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
return
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
else None
)
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
logger.warning("Square subscription payment missing LNbits metadata.")
return
await _handle_square_subscription_payment(
payment,
payment_options,
invoice.get("public_url") or "",
square_subscription_id=subscription_id,
)
async def _handle_square_subscription_payment(
payment: dict,
payment_options: FiatSubscriptionPaymentOptions,
payment_request: str = "",
square_subscription_id: str | None = None,
):
amount_money = payment.get("amount_money") or {}
amount = amount_money.get("amount")
currency = (amount_money.get("currency") or "").upper()
payment_id = payment.get("id")
if amount is None or not currency or not payment_id:
raise ValueError("Square subscription payment event missing payment amount.")
wallet_id = payment_options.wallet_id
if not wallet_id:
raise ValueError("Square subscription payment event missing wallet_id.")
checking_id = f"payment_{payment_id}"
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
if existing_payment:
if (
square_subscription_id
and existing_payment.external_id != square_subscription_id
):
existing_payment.external_id = square_subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
square_subscription_id = square_subscription_id or (
payment_options.extra or {}
).get("square_subscription_id")
extra = {
**(payment_options.extra or {}),
"subscription_request_id": payment_options.subscription_request_id,
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": checking_id,
"payment_request": payment_request,
},
}
lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount / 100,
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
await check_fiat_status(lnbits_payment)
def _square_payment_options_from_payment(
payment: Payment,
) -> FiatSubscriptionPaymentOptions:
extra = payment.extra or {}
return FiatSubscriptionPaymentOptions(
wallet_id=payment.wallet_id,
tag=extra.get("tag") or payment.tag,
subscription_request_id=extra.get("subscription_request_id"),
extra=extra,
memo=payment.memo,
)
def _square_extract_payment(event: dict) -> dict:
event_object = event.get("data", {}).get("object", {})
return event_object.get("payment") or event_object
def _square_payment_is_completed(payment: dict) -> bool:
return (payment.get("status") or "").upper() == "COMPLETED"
def _square_payment_note(payment: dict) -> str:
return payment.get("note") or payment.get("payment_note") or ""
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
if not isinstance(meta, list):
return FiatSubscriptionPaymentOptions()
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+3 -7
View File
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = ( detail = (
str(exc) str(exc)
if isinstance(exc, (AssertionError, ValueError)) if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'." else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})." f"({ext_info.installed_version})."
) )
raise HTTPException( raise HTTPException(
status_code=( status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
detail=detail, detail=detail,
) from exc ) from exc
@@ -292,6 +288,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)]) @extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus: async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extension = await get_installed_extension(ext_id) extension = await get_installed_extension(ext_id)
if not extension: if not extension:
raise HTTPException( raise HTTPException(
@@ -566,7 +563,6 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"shortDescription": ext.short_description, "shortDescription": ext.short_description,
"stars": ext.stars, "stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False, "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 "", "dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids, "isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next( "hasDatabaseTables": next(
+1 -67
View File
@@ -2,35 +2,17 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from loguru import logger from loguru import logger
from pydantic import BaseModel
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services import update_cached_settings
from lnbits.core.services.fiat_providers import test_connection from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat") fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
class RevolutCreateWebhook(BaseModel):
url: str
endpoint: str | None = None
api_secret_key: str | None = None
api_version: str | None = None
class RevolutCreateWebhookResponse(BaseModel):
id: str | None = None
url: str
events: list[str] = []
signing_secret: str
already_exists: bool = False
@fiat_router.put( @fiat_router.put(
"/check/{provider}", "/check/{provider}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
@@ -40,54 +22,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider) return await test_connection(provider)
@fiat_router.post(
"/revolut/webhook",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_create_revolut_webhook(
data: RevolutCreateWebhook,
) -> RevolutCreateWebhookResponse:
try:
webhook = await RevolutWallet.create_webhook(
url=data.url,
endpoint=data.endpoint,
api_secret_key=data.api_secret_key,
api_version=data.api_version,
)
except ValueError as exc:
logger.warning(exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=500, detail="Failed to create Revolut webhook."
) from exc
signing_secret = webhook.get("signing_secret")
webhook_url = webhook.get("url") or data.url
if not signing_secret:
raise HTTPException(
status_code=502, detail="Revolut returned no webhook signing secret."
)
updated_settings = {
"revolut_payment_webhook_url": webhook_url,
"revolut_webhook_signing_secret": signing_secret,
}
for key, value in updated_settings.items():
await set_settings_field(key, value)
update_cached_settings(updated_settings)
return RevolutCreateWebhookResponse(
id=webhook.get("id"),
url=webhook_url,
events=webhook.get("events") or [],
signing_secret=signing_secret,
already_exists=webhook.get("already_exists", False),
)
@fiat_router.post( @fiat_router.post(
"/{provider}/subscription", "/{provider}/subscription",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
-1
View File
@@ -185,7 +185,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/wallets") @generic_router.get("/wallets")
@generic_router.get("/account") @generic_router.get("/account")
@generic_router.get("/extensions") @generic_router.get("/extensions")
@generic_router.get("/blockexplorer")
@generic_router.get("/users", dependencies=admin_ui_checks) @generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks) @generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks) @generic_router.get("/node", dependencies=admin_ui_checks)
-34
View File
@@ -34,7 +34,6 @@ from lnbits.core.models import (
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
UpdatePaymentExtra,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -264,7 +263,6 @@ async def api_payments_create(
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels, labels=invoice_data.labels,
external_id=invoice_data.external_id,
) )
return payment return payment
@@ -298,38 +296,6 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.") return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)
+2 -4
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else: else:
if not database_uri.startswith("postgres://"): if not database_uri.startswith("postgres://"):
raise ValueError( raise ValueError(
"Please use the 'postgres://...' format for the database URL." "Please use the 'postgres://...' " "format for the database URL."
) )
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str: def pagination(self) -> str:
stmt = "" stmt = ""
if self.limit == 0: self.limit = self.limit or 10
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
stmt += f"LIMIT {min(1000, self.limit)} " stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset: if self.offset:
stmt += f"OFFSET {self.offset}" stmt += f"OFFSET {self.offset}"
-6
View File
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings from lnbits.settings import settings
from .paypal import PayPalWallet from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat") fiat_module = importlib.import_module("lnbits.fiat")
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum): class FiatProviderType(Enum):
stripe = "StripeWallet" stripe = "StripeWallet"
paypal = "PayPalWallet" paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None: async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [ __all__ = [
"PayPalWallet", "PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet", "StripeWallet",
] ]
+3 -7
View File
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request." description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.", "If not provided, one will be generated.",
) )
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field( tag: str | None = Field(
default=None, default=None,
description="Payments created by the recurring subscription" description="Payments created by the recurring subscription"
@@ -131,15 +127,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus): class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True # type: ignore[reportIncompatibleVariableOverride] paid = True
class FiatPaymentFailedStatus(FiatPaymentStatus): class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False # type: ignore[reportIncompatibleVariableOverride] paid = False
class FiatPaymentPendingStatus(FiatPaymentStatus): class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None # type: ignore[reportIncompatibleVariableOverride] paid = None
class FiatProvider(ABC): class FiatProvider(ABC):
-646
View File
@@ -1,646 +0,0 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class RevolutCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class RevolutCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: RevolutCheckoutOptions | None = None
class RevolutSubscriptionReference(BaseModel):
wallet_id: str
tag: str | None = None
subscription_request_id: str | None = None
extra: dict[str, Any] | None = None
memo: str | None = None
REVOLUT_WEBHOOK_EVENTS = [
"ORDER_AUTHORISED",
"ORDER_COMPLETED",
"SUBSCRIPTION_INITIATED",
]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant"""
def __init__(self):
logger.debug("Initializing RevolutWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.revolut_api_endpoint:
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
if not settings.revolut_api_secret_key:
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
"Revolut-Api-Version": settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("RevolutWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Revolut wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
)
payload = {
"amount": amount_minor,
"currency": currency.upper(),
"description": checkout.description or memo or "LNbits Invoice",
"redirect_url": success_url,
"metadata": {
**checkout.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
},
}
try:
r = await self.client.post(
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
checkout_url = data.get("checkout_url")
if not order_id or not checkout_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=checkout_url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if quantity != 1:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions do not support quantity.",
)
wallet_id = payment_options.wallet_id
if not wallet_id:
return FiatSubscriptionResponse(
ok=False, error_message="Wallet ID is required."
)
extra = payment_options.extra or {}
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
reference = RevolutSubscriptionReference(
wallet_id=wallet_id,
tag=payment_options.tag,
subscription_request_id=payment_options.subscription_request_id,
extra=extra,
memo=payment_options.memo,
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
),
}
if extra.get("trial_duration"):
payload["trial_duration"] = extra["trial_duration"]
headers = {
**self.headers,
"Idempotency-Key": payment_options.subscription_request_id,
}
try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post(
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
revolut_subscription_id = data.get("id")
setup_order_id = data.get("setup_order_id")
if not revolut_subscription_id or not setup_order_id:
return FiatSubscriptionResponse(
ok=False,
error_message=(
"Server error: missing subscription id or setup order id"
),
)
setup_order = await self.get_order(setup_order_id)
checkout_url = setup_order.get("checkout_url")
if not checkout_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing setup checkout url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=revolut_subscription_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
subscription = await self.get_subscription(subscription_id)
reference = self.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference or reference.wallet_id != correlation_id:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not found."
)
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Revolut does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
order_id = self._normalize_revolut_id(checking_id)
return self._status_from_order(await self.get_order(order_id))
except Exception as exc:
logger.debug(f"Error getting Revolut invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Revolut does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Revolut does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _normalize_revolut_id(self, checking_id: str) -> str:
value = (
checking_id.replace("fiat_revolut_", "", 1)
if checking_id.startswith("fiat_revolut_")
else checking_id
)
return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription_cycle(
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
@classmethod
async def create_webhook(
cls,
url: str,
endpoint: str | None = None,
api_secret_key: str | None = None,
api_version: str | None = None,
) -> dict[str, Any]:
if not url:
raise ValueError("Missing Revolut webhook URL.")
cls._validate_webhook_url(url)
if not endpoint and not settings.revolut_api_endpoint:
raise ValueError("Missing Revolut API endpoint.")
if not api_secret_key and not settings.revolut_api_secret_key:
raise ValueError("Missing Revolut API secret key.")
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
secret_key = api_secret_key or settings.revolut_api_secret_key
headers = {
"Authorization": f"Bearer {secret_key}",
"Revolut-Api-Version": api_version or settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
webhooks = await cls._list_webhooks(client)
existing = await cls._get_existing_webhook(client, webhooks, url)
if existing:
existing["already_exists"] = True
return existing
response = await client.post(
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
@classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
for field in ["webhooks", "data", "items"]:
if isinstance(data.get(field), list):
return data[field]
return []
@classmethod
async def _get_existing_webhook(
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
) -> dict[str, Any] | None:
for webhook in webhooks:
if cls._normalize_webhook_url(webhook.get("url")) != (
cls._normalize_webhook_url(url)
):
continue
webhook_id = webhook.get("id")
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
webhook = response.json()
events = set(webhook.get("events") or [])
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
if missing_events:
raise ValueError(
"A Revolut webhook already exists for this URL, but it is "
f"missing required events: {', '.join(sorted(missing_events))}."
)
if not webhook.get("signing_secret"):
raise ValueError(
"A Revolut webhook already exists for this URL, but Revolut "
"did not return a signing secret."
)
return webhook
return None
@classmethod
def _normalize_webhook_url(cls, url: str | None) -> str:
return (url or "").strip().rstrip("/")
@classmethod
def _validate_webhook_url(cls, url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if parsed.scheme not in ["http", "https"] or not hostname:
raise ValueError("Revolut webhook URL must be a clearnet URL.")
host = hostname.lower()
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
if host.endswith(".local") or host.endswith(".onion"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None:
try:
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Revolut options: {e}")
return None
def _serialize_subscription_reference(
self, reference: RevolutSubscriptionReference
) -> str:
payload = reference.dict(exclude_none=True)
serialized = json.dumps(payload, separators=(",", ":"))
if len(serialized) > 1024:
raise ValueError("Revolut subscription external_reference is too long.")
return serialized
def deserialize_subscription_reference(
self, external_reference: str | None
) -> RevolutSubscriptionReference | None:
if not external_reference:
return None
try:
return RevolutSubscriptionReference.parse_obj(
json.loads(external_reference)
)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning(exc)
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.revolut_api_endpoint),
str(settings.revolut_api_secret_key),
str(settings.revolut_api_version),
str(settings.revolut_webhook_signing_secret),
]
)
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
-620
View File
@@ -1,620 +0,0 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "subscription"]
class SquareCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class SquareSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class SquareCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: SquareCheckoutOptions | None = None
subscription: SquareSubscriptionOptions | None = None
class SquareSubscriptionCheckoutInfo(BaseModel):
plan_variation_id: str
price_money: dict[str, Any]
class SquareWallet(FiatProvider):
"""https://developer.squareup.com/reference/square"""
def __init__(self):
logger.debug("Initializing SquareWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.square_api_endpoint:
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
if not settings.square_access_token:
raise ValueError("Cannot initialize SquareWallet: missing access token.")
if not settings.square_location_id:
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
self.location_id = settings.square_location_id
self.headers = {
"Authorization": f"Bearer {settings.square_access_token}",
"Square-Version": settings.square_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("SquareWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Square wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
if opts.fiat_method == "subscription":
return self._create_subscription_invoice(opts.subscription)
return await self._create_checkout_invoice(
amount=amount,
payment_hash=payment_hash,
currency=currency,
opts=opts,
memo=memo,
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if settings.lnbits_running:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not supported for Square."
)
success_url = (
payment_options.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
checkout_info = await self._get_subscription_checkout_info(subscription_id)
metadata = self._serialize_metadata(payment_options)
payload = {
"idempotency_key": payment_options.subscription_request_id,
"description": metadata,
"quick_pay": {
"name": (payment_options.memo or "LNbits Subscription")[:255],
"price_money": checkout_info.price_money,
"location_id": self.location_id,
},
"checkout_options": {
"redirect_url": success_url,
"subscription_plan_id": checkout_info.plan_variation_id,
},
"payment_note": metadata,
}
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
url = payment_link.get("url")
if not url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
square_subscription_id = await self._get_square_subscription_id(
subscription_id, correlation_id
)
r = await self.client.post(
f"/v2/subscriptions/{square_subscription_id}/cancel"
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Square does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
square_id = self._normalize_square_id(checking_id)
if square_id.startswith("payment_"):
payment_id = square_id.replace("payment_", "", 1)
return await self._get_payment_status(payment_id)
order_id = (
square_id.replace("order_", "", 1)
if square_id.startswith("order_")
else square_id
)
return await self._get_order_status(order_id)
except Exception as exc:
logger.debug(f"Error getting Square invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Square does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Square does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if payment_id:
return await self._get_payment_status(payment_id)
if (order.get("state") or "").upper() == "CANCELED":
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/orders/{order_id}")
r.raise_for_status()
return r.json().get("order") or {}
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if not payment_id:
return None
return await self._get_payment(payment_id)
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
tenders = order.get("tenders") or []
for tender in tenders:
payment_id = tender.get("payment_id")
if payment_id:
return payment_id
return None
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
return self._status_from_payment(await self._get_payment(payment_id))
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/payments/{payment_id}")
r.raise_for_status()
return r.json().get("payment") or {}
async def _get_subscription_checkout_info(
self, subscription_plan_id: str
) -> SquareSubscriptionCheckoutInfo:
catalog_object = await self._get_catalog_object(subscription_plan_id)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
return await self._get_plan_checkout_info(catalog_object)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
price_money = await self._get_subscription_price_money(
catalog_object,
)
plan_variation_id = catalog_object.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
raise ValueError(
"Square subscription ID must be a plan ID or plan variation ID."
)
async def _get_plan_checkout_info(
self, catalog_object: dict[str, Any]
) -> SquareSubscriptionCheckoutInfo:
plan_data = catalog_object.get("subscription_plan_data") or {}
plan_variations = plan_data.get("subscription_plan_variations") or []
eligible_item_ids = plan_data.get("eligible_item_ids") or []
plan_variation = next(
(
variation
for variation in plan_variations
if not variation.get("is_deleted")
),
None,
)
if not plan_variation:
raise ValueError("Square subscription plan is missing a variation.")
price_money = await self._get_subscription_price_money(
plan_variation,
eligible_item_ids=eligible_item_ids,
)
plan_variation_id = plan_variation.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/catalog/object/{object_id}")
r.raise_for_status()
return r.json().get("object") or {}
async def _get_subscription_price_money(
self,
plan_variation: dict[str, Any],
eligible_item_ids: list[str] | None = None,
) -> dict[str, Any]:
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
phases = variation_data.get("phases") or []
for phase in phases:
pricing = phase.get("pricing") or {}
price_money = pricing.get("price_money") or phase.get(
"recurring_price_money"
)
parsed_price_money = self._parse_price_money(price_money)
if parsed_price_money:
return parsed_price_money
if pricing.get("type") == "RELATIVE":
return await self._get_relative_subscription_price_money(
eligible_item_ids or []
)
raise ValueError("Square subscription plan variation is missing price_money.")
async def _get_relative_subscription_price_money(
self, eligible_item_ids: list[str]
) -> dict[str, Any]:
if len(eligible_item_ids) != 1:
raise ValueError(
"Square relative subscription plan must have exactly one item."
)
item = await self._get_catalog_object(eligible_item_ids[0])
item_variations: list[dict[str, Any]] = []
if item.get("type") == "ITEM":
item_variations = (item.get("item_data") or {}).get("variations") or []
elif item.get("type") == "ITEM_VARIATION":
item_variations = [item]
item_variation = next(
(
variation
for variation in item_variations
if not variation.get("is_deleted")
),
None,
)
if not item_variation:
raise ValueError("Square subscription item is missing a variation.")
price_money = self._parse_price_money(
(item_variation.get("item_variation_data") or {}).get("price_money")
)
if price_money:
return price_money
raise ValueError("Square subscription item variation is missing price_money.")
def _parse_price_money(
self, price_money: dict[str, Any] | None
) -> dict[str, Any] | None:
if (
price_money
and price_money.get("amount") is not None
and price_money.get("currency")
):
return {
"amount": int(price_money["amount"]),
"currency": price_money["currency"].upper(),
}
return None
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
status = (payment.get("status") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _create_checkout_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
opts: SquareCreateInvoiceOptions,
memo: str | None = None,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
co = opts.checkout or SquareCheckoutOptions()
success_url = (
co.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
metadata = {
**co.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
}
payload = {
"idempotency_key": payment_hash,
"order": {
"location_id": self.location_id,
"metadata": metadata,
"line_items": [
{
"name": line_item_name,
"quantity": "1",
"base_price_money": {
"amount": amount_cents,
"currency": currency.upper(),
},
}
],
},
"checkout_options": {"redirect_url": success_url},
}
if memo:
payload["payment_note"] = memo[:500]
try:
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
order_id = payment_link.get("order_id")
url = payment_link.get("url")
if not order_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _create_subscription_invoice(
self, opts: SquareSubscriptionOptions | None
) -> FiatInvoiceResponse:
term = opts or SquareSubscriptionOptions()
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
return FiatInvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=term.payment_request or "",
)
def _normalize_square_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_square_", "", 1)
if checking_id.startswith("fiat_square_")
else checking_id
)
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> SquareCreateInvoiceOptions | None:
try:
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Square options: {e}")
return None
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
extra_link = None
if payment_options.extra:
raw_link = payment_options.extra.get("link")
extra_link = str(raw_link)[:200] if raw_link else None
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
extra_link,
]
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
else:
meta.append(None)
metadata = json.dumps(meta, separators=(",", ":"))
if len(metadata) > 500:
raise ValueError("Square subscription metadata is too long.")
return metadata
async def _get_square_subscription_id(
self, subscription_id: str, wallet_id: str
) -> str:
try:
from lnbits.core.crud.payments import get_payments
from lnbits.core.models import PaymentFilters
from lnbits.db import Filter, Filters
payments = await get_payments(
wallet_id=wallet_id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id and payment.fiat_provider == "square"
),
None,
)
if payment and payment.external_id:
return payment.external_id
payments = await get_payments(
wallet_id=wallet_id,
incoming=True,
filters=Filters(
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id
and payment.fiat_provider == "square"
and (payment.extra or {}).get("subscription_request_id")
== subscription_id
),
None,
)
if payment and payment.external_id:
return payment.external_id
except Exception as exc:
logger.warning(exc)
return subscription_id
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.square_api_endpoint),
str(settings.square_access_token),
str(settings.square_location_id),
str(settings.square_api_version),
]
)
-78
View File
@@ -1,78 +0,0 @@
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
from typing import Any
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
def generate_llms_txt(app: FastAPI) -> str:
"""Convert an OpenAPI schema to llms.txt markdown format."""
openapi_schema = app.openapi()
lines: list[str] = []
# H1: API Title
info = openapi_schema.get("info", {})
title = info.get("title", "API")
lines.append(f"# {title}")
lines.append("")
# Blockquote: Description
description = info.get("description")
if description:
for line in description.strip().split("\n"):
lines.append(f"> {line}")
lines.append("")
# Group endpoints by tag
paths = openapi_schema.get("paths", {})
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
for path, path_item in paths.items():
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
if method not in path_item:
continue
operation = path_item[method]
tags = operation.get("tags", ["Endpoints"])
tag = tags[0] if tags else "Endpoints"
if tag not in endpoints_by_tag:
endpoints_by_tag[tag] = []
endpoints_by_tag[tag].append(
{
"path": path,
"method": method.upper(),
"operation": operation,
}
)
# Generate sections by tag
for tag, endpoints in endpoints_by_tag.items():
lines.append(f"## {tag}")
lines.append("")
for endpoint in endpoints:
method = endpoint["method"]
path = endpoint["path"]
operation = endpoint["operation"]
summary = operation.get("summary", "")
if summary:
lines.append(f"### `{method} {path}` - {summary}")
else:
lines.append(f"### `{method} {path}`")
lines.append("")
lines.append("")
return "\n".join(lines).strip() + "\n"
def create_llms_txt_route(app: FastAPI) -> None:
"""Add a /llms.txt endpoint to the app."""
@app.get(
"/llms.txt",
response_class=PlainTextResponse,
include_in_schema=False,
summary="Get LLM-friendly API documentation",
)
async def get_llms_txt() -> str:
"""Return the API documentation in llms.txt markdown format."""
return generate_llms_txt(app)
-14
View File
@@ -7,7 +7,6 @@ from typing import Any
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
): ):
return RedirectResponse("/first_install") return RedirectResponse("/first_install")
return await call_next(request) return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+12 -84
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg") lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by") lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field( lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png" default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -300,7 +300,6 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True) lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True) lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False) lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings): class OpsSettings(LNbitsSettings):
@@ -324,6 +323,10 @@ class AssetSettings(LNbitsSettings):
"heic", "heic",
"heif", "heif",
"heics", "heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
] ]
) )
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0) lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -359,11 +362,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings): class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0) lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: 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_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( lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[ default=[
@@ -587,9 +588,6 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings): class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/") phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None) 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): class AlbyFundingSource(LNbitsSettings):
@@ -614,7 +612,6 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765") spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None) spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: 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_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0) spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0) spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -652,7 +649,6 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="") boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None) boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None) boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings): class StrikeFundingSource(LNbitsSettings):
@@ -704,35 +700,6 @@ class PayPalFiatProvider(LNbitsSettings):
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class SquareFiatProvider(LNbitsSettings):
square_enabled: bool = Field(default=False)
square_api_endpoint: str = Field(default="https://connect.squareup.com")
square_access_token: str | None = Field(default=None)
square_location_id: str | None = Field(default=None)
square_api_version: str = Field(default="2026-01-22")
square_payment_success_url: str = Field(default="https://lnbits.com")
square_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
)
square_webhook_signature_key: str | None = Field(default=None)
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class RevolutFiatProvider(LNbitsSettings):
revolut_enabled: bool = Field(default=False)
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
revolut_api_secret_key: str | None = Field(default=None)
revolut_api_version: str = Field(default="2026-04-20")
revolut_payment_success_url: str = Field(default="https://lnbits.com")
revolut_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
)
revolut_webhook_signing_secret: str | None = Field(default=None)
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -770,12 +737,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings( class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
def is_fiat_provider_enabled(self, provider: str | None) -> bool: def is_fiat_provider_enabled(self, provider: str | None) -> bool:
""" """
Checks if a specific fiat provider is enabled. Checks if a specific fiat provider is enabled.
@@ -786,10 +748,6 @@ class FiatProvidersSettings(
return self.stripe_enabled return self.stripe_enabled
if provider == "paypal": if provider == "paypal":
return self.paypal_enabled return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]: def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -809,18 +767,6 @@ class FiatProvidersSettings(
): ):
allowed_providers.append("paypal") allowed_providers.append("paypal")
if self.square_enabled and (
not self.square_limits.allowed_users
or user_id in self.square_limits.allowed_users
):
allowed_providers.append("square")
if self.revolut_enabled and (
not self.revolut_limits.allowed_users
or user_id in self.revolut_limits.allowed_users
):
allowed_providers.append("revolut")
return allowed_providers return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -845,16 +791,6 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False) 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): class AuthMethods(Enum):
user_id_only = "user-id-only" user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105 username_and_password = "username-password" # noqa: S105
@@ -1028,7 +964,6 @@ class EditableSettings(
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
BlockExplorerSettings,
AuditSettings, AuditSettings,
AuthSettings, AuthSettings,
NostrAuthSettings, NostrAuthSettings,
@@ -1063,7 +998,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
class Config(EditableSettings.Config): class Config:
extra = Extra.forbid extra = Extra.forbid
@@ -1071,8 +1006,8 @@ class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False) debug: bool = Field(default=False)
debug_database: bool = Field(default=False) debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP. # When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True) auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1") host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0) port: int = Field(default=5000, gt=0)
@@ -1088,12 +1023,9 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB") log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months") log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None) first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0) cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, 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 @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
@@ -1215,11 +1147,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc] class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
case_sensitive = False case_sensitive = False
json_loads = list_parse_fallback # type: ignore[assignment] json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str) -> bool: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
@@ -1279,7 +1211,6 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey") webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions") show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit") show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin") show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace") ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle") ad_space_title: str = Field(alias="adSpaceTitle")
@@ -1300,7 +1231,6 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded") default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient") default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow") default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field() denomination: str | None = Field()
extensions: list[str] = Field() extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies") allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1347,7 +1277,6 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey, webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all, showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled, showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui, showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image, customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge, customBadge=settings.lnbits_custom_badge,
@@ -1365,7 +1294,6 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded, defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient, defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow, defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination, denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids), extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies, allowedCurrencies=settings.lnbits_allowed_currencies,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -18
View File
File diff suppressed because one or more lines are too long
+4 -14
View File
@@ -212,15 +212,12 @@ body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */ backdrop-filter: none; /* Ensure the page content is not affected */
} }
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
}
body.body--dark .q-header, body.body--dark .q-header,
body.body--dark .q-drawer { body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3); --q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: brightness(0.8); backdrop-filter: blur(6px) brightness(0.8);
} }
body.rounded-ui .q-card, body.rounded-ui .q-card,
@@ -391,18 +388,11 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
} }
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
} }
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45); filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
body.no-burger-background .q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
} }
:root { :root {
+1 -44
View File
@@ -824,48 +824,5 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados', payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor', color: 'Cor',
sort: 'Ordenar', 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'
} }
+1 -40
View File
@@ -419,44 +419,5 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法', http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码', http_response_codes: 'HTTP响应代码',
request_details: '请求详情', 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} 区块手续费'
} }
+1 -44
View File
@@ -439,48 +439,5 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků', http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí', http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti', 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ů'
} }
+1 -44
View File
@@ -452,48 +452,5 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden', http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes', http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails', 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'
} }
+1 -67
View File
@@ -184,7 +184,6 @@ window.localisation.en = {
release_notes: 'Release Notes', release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users', activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured', featured: 'Featured',
categories: 'Categories',
all: 'All', all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)', only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions: only_admins_can_create_extensions:
@@ -268,15 +267,6 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:', webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description: webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.', 'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
webhook_square_description:
'On the Square side configure a webhook pointing to this exact LNbits URL.',
square_webhook_url_hint:
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
access_token: 'Access Token',
location_id: 'Location ID',
square_location_id_hint:
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
api_version: 'API Version',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@@ -285,8 +275,6 @@ window.localisation.en = {
requires_server_restart: requires_server_restart:
'Changing these settings requires a server restart to take effect.', 'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet', funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.', latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
notifications_configure: 'Configure Notifications', notifications_configure: 'Configure Notifications',
@@ -491,8 +479,6 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards', toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow', card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards', toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language', language: 'Language',
assets: 'Assets', assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)', max_asset_size_mb: 'Max Asset Size (MB)',
@@ -817,8 +803,6 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.', webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description: webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.', 'On the PayPal side configure a webhook pointing to your LNbits server.',
square_webhook_signature_key_hint:
'Square webhook signature key used to verify incoming events.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: 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',
@@ -841,55 +825,5 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated', payment_labels_updated: 'Payment labels updated',
color: 'Color', color: 'Color',
sort: 'Sort', sort: 'Sort',
sort_by: 'Sort by', sort_by: 'Sort by'
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'
} }
+1 -44
View File
@@ -453,48 +453,5 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP', http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP', http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud', 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'
} }
+1 -44
View File
@@ -647,48 +647,5 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.', 'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: 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'
} }
+1 -44
View File
@@ -456,48 +456,5 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP', http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP', http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande', 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'
} }
+1 -44
View File
@@ -450,48 +450,5 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP', http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP', http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta', 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'
} }
+1 -44
View File
@@ -440,48 +440,5 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド', http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード', http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細', 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}ブロック手数料'
} }
+1 -43
View File
@@ -435,47 +435,5 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드', http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드', http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항', 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}블록 수수료'
} }
+1 -44
View File
@@ -450,48 +450,5 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden', http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes', http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails', 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'
} }
+1 -44
View File
@@ -441,48 +441,5 @@ window.localisation.pi = {
http_request_methods: 'HTTP Request Methods', http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes', http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details', request_details: 'Request Details',
http_request_details: 'HTTP Request Details', http_request_details: 'HTTP Request Details'
block_explorer: 'Treasure Map',
enable_block_explorer: 'Hoist the Treasure Map',
block_explorer_desc:
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
blockexplorer_public_api: 'Open Seas API',
blockexplorer_public_api_desc:
'Allow any landlubber access to the block explorer API ports.',
electrum_server_url: 'Electrum Port URL',
electrum_server_url_hint:
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
blockexplorer_search_label: 'Search by TXID or Port',
blockexplorer_search_hint:
'64-char hex = plunder · anything else = Bitcoin port',
recent_blocks: 'Recent Plunder',
chain_tip: "Tip o' the Anchor Chain",
block_height: 'Plunder Height',
block_fee: 'plunder fee',
fee_estimates: 'Booty Estimates',
confirmed_balance: 'Confirmed Booty',
unconfirmed_balance: 'Unconfirmed Booty',
transaction_history: 'Plunder History',
coinbase: 'Coinbase',
inputs: 'Inbound Plunder',
outputs: 'Outbound Plunder',
confirmations: 'Confirmations, arr',
confirmed: 'Confirmed, arr',
unconfirmed: 'Unconfirmed, arr',
history_unavailable:
'Plunder history lost at sea (too many transactions, matey!)',
address: 'Port',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Plunder 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 booty'
} }
+1 -44
View File
@@ -444,48 +444,5 @@ window.localisation.pl = {
http_request_methods: 'Metody żądań HTTP', http_request_methods: 'Metody żądań HTTP',
http_response_codes: 'Kody Odpowiedzi HTTP', http_response_codes: 'Kody Odpowiedzi HTTP',
request_details: 'Szczegóły żądania', request_details: 'Szczegóły żądania',
http_request_details: 'Szczegóły żądania HTTP', http_request_details: 'Szczegóły żądania HTTP'
block_explorer: 'Przeglądarka bloków',
enable_block_explorer: 'Włącz przeglądarkę bloków',
block_explorer_desc:
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
blockexplorer_public_api: 'Publiczny dostęp do API',
blockexplorer_public_api_desc:
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
electrum_server_url: 'URL serwera Electrum',
electrum_server_url_hint:
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
blockexplorer_search_hint:
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
recent_blocks: 'Ostatnie bloki',
chain_tip: 'Wierzchołek łańcucha',
block_height: 'Wysokość bloku',
block_fee: 'opłata bloku',
fee_estimates: 'Szacunki opłat',
confirmed_balance: 'Potwierdzony saldo',
unconfirmed_balance: 'Niepotwierdzony saldo',
transaction_history: 'Historia transakcji',
coinbase: 'Coinbase',
inputs: 'Wejścia',
outputs: 'Wyjścia',
confirmations: 'Potwierdzenia',
confirmed: 'Potwierdzone',
unconfirmed: 'Niepotwierdzone',
history_unavailable:
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'trud. {value}',
block_hash: 'Hash',
previous_block: 'Poprzedni blok',
merkle_root: 'Korzeń Merkle',
version: 'Wersja',
bits: 'Bity',
difficulty: 'Trudność',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Rozmiar wirtualny',
weight: 'Waga',
n_block_fee: 'opłata {n} bloków'
} }
+1 -44
View File
@@ -446,48 +446,5 @@ window.localisation.pt = {
http_request_methods: 'Métodos de Requisição HTTP', http_request_methods: 'Métodos de Requisição HTTP',
http_response_codes: 'Códigos de Resposta HTTP', http_response_codes: 'Códigos de Resposta HTTP',
request_details: 'Detalhes da solicitação', request_details: 'Detalhes da solicitação',
http_request_details: 'Detalhes da Solicitação HTTP', http_request_details: 'Detalhes da Solicitação HTTP'
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos utilizadores 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 demasiadas transações)',
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'
} }
+1 -44
View File
@@ -444,48 +444,5 @@ window.localisation.sk = {
http_request_methods: 'Metódy HTTP žiadostí', http_request_methods: 'Metódy HTTP žiadostí',
http_response_codes: 'Kódy odpovedí HTTP', http_response_codes: 'Kódy odpovedí HTTP',
request_details: 'Podrobnosti žiadosti', request_details: 'Podrobnosti žiadosti',
http_request_details: 'Podrobnosti požiadavky HTTP', http_request_details: 'Podrobnosti požiadavky HTTP'
block_explorer: 'Prehliadač blokov',
enable_block_explorer: 'Povoliť prehliadač blokov',
block_explorer_desc:
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
blockexplorer_public_api: 'Verejný prístup k API',
blockexplorer_public_api_desc:
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
electrum_server_url: 'URL Electrum servera',
electrum_server_url_hint:
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
recent_blocks: 'Nedávne bloky',
chain_tip: 'Vrchol reťaze',
block_height: 'Výška bloku',
block_fee: 'poplatok bloku',
fee_estimates: 'Odhady poplatkov',
confirmed_balance: 'Potvrdený zostatok',
unconfirmed_balance: 'Nepotvrdený zostatok',
transaction_history: 'História transakcií',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrdenia',
confirmed: 'Potvrdené',
unconfirmed: 'Nepotvrdené',
history_unavailable:
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Predchádzajúci blok',
merkle_root: 'Merkle koreň',
version: 'Verzia',
bits: 'Bity',
difficulty: 'Obťažnosť',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuálna veľkosť',
weight: 'Váha',
n_block_fee: 'poplatok {n} blokov'
} }
+1 -44
View File
@@ -442,48 +442,5 @@ window.localisation.we = {
http_request_methods: 'Dulliau Cais HTTP', http_request_methods: 'Dulliau Cais HTTP',
http_response_codes: 'Codau Ymateb HTTP', http_response_codes: 'Codau Ymateb HTTP',
request_details: 'Manylion y Cais', request_details: 'Manylion y Cais',
http_request_details: 'Manylion Cais HTTP', http_request_details: 'Manylion Cais HTTP'
block_explorer: 'Archwiliwr Bloc',
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
block_explorer_desc:
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
blockexplorer_public_api_desc:
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
electrum_server_url: 'URL Gweinydd Electrum',
electrum_server_url_hint:
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
blockexplorer_search_hint:
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
recent_blocks: 'Blociau Diweddar',
chain_tip: 'Blaen y Gadwyn',
block_height: 'Uchder Bloc',
block_fee: 'ffi bloc',
fee_estimates: 'Amcangyfrifon Ffi',
confirmed_balance: 'Balans Cadarnhawyd',
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
transaction_history: 'Hanes Trafodion',
coinbase: 'Coinbase',
inputs: 'Mewnbynnau',
outputs: 'Allbynnau',
confirmations: 'Cadarnhadau',
confirmed: 'Cadarnhawyd',
unconfirmed: 'Heb ei Gadarnhau',
history_unavailable:
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
address: 'Cyfeiriad',
block_number: 'Bloc #{height}',
block_diff: 'anhawster {value}',
block_hash: 'Hash',
previous_block: 'Bloc Blaenorol',
merkle_root: 'Gwreiddyn Merkle',
version: 'Fersiwn',
bits: 'Bits',
difficulty: 'Anhawster',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Maint Rhithwir',
weight: 'Pwysau',
n_block_fee: 'ffi {n} bloc'
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

-9
View File
@@ -193,14 +193,5 @@ window._lnbitsApi = {
return LNbits.api return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`) .request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
getBlockexplorerAddress(address) {
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
},
getBlockexplorerTransaction(txid) {
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
},
getBlockexplorerUtxos(address) {
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
} }
} }
+1 -3
View File
@@ -452,9 +452,7 @@ window.app.component('username-password', {
confirmationMethod: 'code', confirmationMethod: 'code',
confirmationEmail: '', confirmationEmail: '',
confirmationCode: this.invitationCode || '', confirmationCode: this.invitationCode || '',
showConfirmationCode: false, showConfirmationCode: false
showPwd: false,
showPwdRepeat: false
} }
}, },
methods: { methods: {
@@ -1,40 +0,0 @@
window.app.component('lnbits-admin-blockexplorer', {
props: ['form-data'],
template: '#lnbits-admin-blockexplorer',
data() {
return {
electrumServers: [
'ssl://fulcrum.lnbits.com:50002',
'ssl://mainnet.nunchuk.io:52002',
'ssl://fulcrum.grey.pw:50002',
'ssl://electrum2.bluewallet.io:443',
'ssl://electrum.acinq.co:50002',
'ssl://electrum.blockstream.info:50002',
'ssl://bitcoin.mullvad.net:5010'
]
}
},
computed: {
electrumServerOptions() {
return [...this.electrumServers, 'Custom']
},
electrumServerPreset: {
get() {
return this.electrumServers.includes(
this.formData.lnbits_blockexplorer_electrum_url
)
? this.formData.lnbits_blockexplorer_electrum_url
: 'Custom'
},
set(value) {
if (value === 'Custom') {
if (this.electrumServerPreset !== 'Custom') {
this.formData.lnbits_blockexplorer_electrum_url = ''
}
return
}
this.formData.lnbits_blockexplorer_electrum_url = value
}
}
}
})
@@ -62,6 +62,12 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() { mounted() {
this.getExchangeRateHistory() this.getExchangeRateHistory()
}, },
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: { methods: {
getDefaultSetting(fieldName) { getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => { LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -121,21 +127,18 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true this.exchangeData.showTickerConversion = true
}, },
initExchangeChart(data) { initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d => const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm') this.utils.formatTimestamp(d.timestamp, 'HH:mm')
) )
const exchanges = this.formData.lnbits_price_aggregator_enabled const exchanges = [
? [{name: 'Aggregator'}] ...this.formData.lnbits_exchange_rate_providers,
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}] {name: 'LNbits'}
]
const datasets = exchanges.map(exchange => ({ const datasets = exchanges.map(exchange => ({
label: exchange.name, label: exchange.name,
data: data.map(d => d.rates[exchange.name]), data: data.map(d => d.rates[exchange.name]),
pointStyle: true, pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 2, borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4 tension: 0.4
})) }))
this.exchangeRatesChart = new Chart( this.exchangeRatesChart = new Chart(
@@ -145,11 +148,7 @@ window.app.component('lnbits-admin-exchange-providers', {
options: { options: {
plugins: { plugins: {
legend: { legend: {
display: true display: false
},
title: {
display: true,
text: 'Bitcoin Price History'
} }
} }
}, },
@@ -5,9 +5,6 @@ window.app.component('lnbits-admin-fiat-providers', {
return { return {
formAddStripeUser: '', formAddStripeUser: '',
formAddPaypalUser: '', formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true hideInputToggle: true
} }
}, },
@@ -23,12 +20,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url || this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal') this.calculateWebhookUrl('paypal')
) )
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
} }
}, },
watch: { watch: {
@@ -67,8 +58,6 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() { syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe') this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal') this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
}, },
maybeSetWebhookUrl(fieldName, provider) { maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) { if (!this.formData) {
@@ -88,47 +77,6 @@ window.app.component('lnbits-admin-fiat-providers', {
} }
this.copyText(url) this.copyText(url)
}, },
isClearnetWebhookUrl(url) {
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (e) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false
}
if (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
host.endsWith('.onion')
) {
return false
}
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host === '0.0.0.0' ||
host === '::1'
) {
return false
}
return true
},
notifyRevolutWebhookWarning(message) {
Quasar.Notify.create({
type: 'warning',
message,
icon: null,
closeBtn: true
})
},
addStripeAllowedUser() { addStripeAllowedUser() {
const addUser = this.formAddStripeUser || '' const addUser = this.formAddStripeUser || ''
if ( if (
@@ -163,40 +111,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users = this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user) this.formData.paypal_limits.allowed_users.filter(u => u !== user)
}, },
addSquareAllowedUser() {
const addUser = this.formAddSquareUser || ''
if (
addUser.length &&
!this.formData.square_limits.allowed_users.includes(addUser)
) {
this.formData.square_limits.allowed_users = [
...this.formData.square_limits.allowed_users,
addUser
]
this.formAddSquareUser = ''
}
},
removeSquareAllowedUser(user) {
this.formData.square_limits.allowed_users =
this.formData.square_limits.allowed_users.filter(u => u !== user)
},
addRevolutAllowedUser() {
const addUser = this.formAddRevolutUser || ''
if (
addUser.length &&
!this.formData.revolut_limits.allowed_users.includes(addUser)
) {
this.formData.revolut_limits.allowed_users = [
...this.formData.revolut_limits.allowed_users,
addUser
]
this.formAddRevolutUser = ''
}
},
removeRevolutAllowedUser(user) {
this.formData.revolut_limits.allowed_users =
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) { checkFiatProvider(providerName) {
LNbits.api LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`) .request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -210,48 +124,6 @@ window.app.component('lnbits-admin-fiat-providers', {
}) })
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
createRevolutWebhook() {
const webhookUrl = this.calculateWebhookUrl('revolut')
this.formData.revolut_payment_webhook_url = webhookUrl
if (!this.formData.revolut_api_secret_key) {
this.notifyRevolutWebhookWarning(
'Add your Revolut API secret key before creating a webhook.'
)
return
}
if (!this.isClearnetWebhookUrl(webhookUrl)) {
this.notifyRevolutWebhookWarning(
'Revolut webhook URL must be a clearnet URL.'
)
return
}
this.creatingRevolutWebhook = true
LNbits.api
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
url: webhookUrl,
endpoint: this.formData.revolut_api_endpoint,
api_secret_key: this.formData.revolut_api_secret_key,
api_version: this.formData.revolut_api_version
})
.then(response => {
const data = response.data
this.formData.revolut_payment_webhook_url = data.url
this.formData.revolut_webhook_signing_secret = data.signing_secret
Quasar.Notify.create({
type: 'positive',
message: `Revolut webhook ${
data.already_exists ? 'already exists' : 'created'
}${data.id ? `: ${data.id}` : ''}.`,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.creatingRevolutWebhook = false
})
} }
} }
}) })
@@ -1,151 +0,0 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd', 'Phoenixd',
{ {
phoenixd_api_endpoint: 'Endpoint', phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key', phoenixd_api_password: 'Key'
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
} }
], ],
[ [
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', { window.app.component('lnbits-admin-funding', {
props: ['active', 'is-super-user', 'form-data', 'settings'], props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding', template: '#lnbits-admin-funding',
data() { data() {
return { return {
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
] ]
} }
}, },
methods: { methods: {}
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}) })
@@ -360,37 +360,18 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
async exportCSV(detailed = false) { exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
const pagination = this.paymentsTable.pagination const pagination = this.paymentsTable.pagination
const maxPages = 100 const query = {
const limit = 1000 sortby: pagination.sortBy ?? 'time',
let payments = [] direction: pagination.descending ? 'desc' : 'asc'
}
this.paymentsCSV.loading = true const params = new URLSearchParams(query)
try { LNbits.api.getPayments(this.wallet, params).then(response => {
for (let page = 0; page < maxPages; page++) { let payments = response.data.data.map(this.mapPayment)
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc',
limit,
offset: page * limit
}
const params = new URLSearchParams(query)
const response = await LNbits.api.getPayments(this.wallet, params)
const pagePayments = response.data.data || []
payments = payments.concat(pagePayments.map(this.mapPayment))
if (
pagePayments.length < limit ||
payments.length >= response.data.total
) {
break
}
}
let columns = this.paymentsCSV.columns let columns = this.paymentsCSV.columns
if (detailed) { if (detailed) {
@@ -419,11 +400,7 @@ window.app.component('lnbits-payment-list', {
payments, payments,
this.wallet.name + '-payments' this.wallet.name + '-payments'
) )
} catch (err) { })
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
}, },
addFilterTag() { addFilterTag() {
if (!this.exportTagName) return if (!this.exportTagName) return
@@ -8,10 +8,6 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: { prefix: {
type: String, type: String,
default: 'lnurlp' default: 'lnurlp'
},
href: {
type: String,
default: ''
} }
}, },
data() { data() {
@@ -25,10 +21,7 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') { if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url) const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes) const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = this.lnurl = `lightning:${bech32.toUpperCase()}`
this.href && this.href.trim() !== ''
? `${this.href}?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') { } else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) { if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://') this.lnurl = this.url.replace('http://', this.prefix + '://')
@@ -85,9 +85,6 @@ window.app.component('lnbits-qrcode', {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return false return false
} else if (this.href && this.href.startsWith('http')) {
window.open(this.href, '_blank')
event.preventDefault()
} }
}, },
async writeNfcTag() { async writeNfcTag() {
@@ -69,14 +69,6 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow') document.body.classList.remove('card-shadow')
} }
}, },
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) { 'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val) this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) { if (val === true) {
@@ -158,9 +150,6 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) { if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow') document.body.classList.add('card-shadow')
} }
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') { if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image') document.body.classList.add('bg-image')
document.body.style.setProperty( document.body.style.setProperty(
+5 -6
View File
@@ -1,17 +1,16 @@
function eventReaction(amount) { function eventReaction(amount) {
localUrl = '' localUrl = ''
const reaction = reaction = localStorage.getItem('lnbits.reactions')
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction if (!reaction || reaction === 'None') {
if (!reaction || reaction.toLowerCase() === 'none') {
return return
} }
try { try {
if (amount < 0) { if (amount < 0) {
return return
} }
if (typeof window[reaction] === 'function') { reaction = localStorage.getItem('lnbits.reactions')
window[reaction]() if (reaction) {
window[reaction.split('|')[1]]()
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
-4
View File
@@ -29,10 +29,6 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient SETTINGS.defaultCardGradient
), ),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow), cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction), reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore( bgimageChoice: localStore(
'lnbits.backgroundImage', 'lnbits.backgroundImage',
-37
View File
@@ -27,14 +27,6 @@ const DynamicComponent = {
name: r.name, name: r.name,
component: async () => { component: async () => {
await LNbits.utils.loadTemplate(r.template) await LNbits.utils.loadTemplate(r.template)
if (r.i18n) {
const locale =
window.i18n?.global?.locale?.value ??
window.i18n?.global?.locale ??
window.g.locale ??
'en'
await LNbits.utils.loadExtI18n(r.i18n, locale)
}
await LNbits.utils.loadScript(r.component) await LNbits.utils.loadScript(r.component)
return window[r.name] return window[r.name]
} }
@@ -66,11 +58,6 @@ const routes = [
name: 'NodePublic', name: 'NodePublic',
component: PageNodePublic component: PageNodePublic
}, },
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{ {
path: '/payments', path: '/payments',
name: 'Payments', name: 'Payments',
@@ -164,30 +151,6 @@ window.i18n = new VueI18n.createI18n({
fallbackLocale: 'en', fallbackLocale: 'en',
messages: window.localisation messages: window.localisation
}) })
;(function () {
let _applying = false
let _target = null
Vue.watch(
() => window.i18n.global.locale,
async (locale, prevLocale) => {
if (_applying || !LNbits.utils._extI18nDirs.size) return
_target = locale
_applying = true
window.i18n.global.locale = prevLocale
_applying = false
await Promise.all(
[...LNbits.utils._extI18nDirs].map(dir =>
LNbits.utils.loadExtI18n(dir, locale)
)
)
if (_target !== locale) return
_applying = true
window.i18n.global.locale = locale
_applying = false
},
{flush: 'sync'}
)
})()
window.app.mixin({ window.app.mixin({
data() { data() {
+9 -35
View File
@@ -545,56 +545,31 @@ window.PageAccount = {
if (file) { if (file) {
this.uploadAsset(file) this.uploadAsset(file)
} }
e.target.value = null
}, },
onBackgroundImageInput(e) { async uploadAsset(file) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
try { try {
const {data} = await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/api/v1/assets?public_asset=${isPublic}`, `/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
null, null,
formData, formData,
{ {
headers: {'Content-Type': 'multipart/form-data'} headers: {'Content-Type': 'multipart/form-data'}
} }
) )
if (notifySuccess) { this.$q.notify({
this.$q.notify({ type: 'positive',
type: 'positive', message: 'Upload successful!',
message: 'Upload successful!', icon: null
icon: null })
})
}
await this.getUserAssets() await this.getUserAssets()
return data
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) { async deleteAsset(asset) {
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?') .confirmDialog('Are you sure you want to delete this asset?')
@@ -732,8 +707,7 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark, darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded, cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient, cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow, cardShadowChoice: this.g.settings.defaultCardShadow
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
} }
this.siteCustomisationChanged(defaults) this.siteCustomisationChanged(defaults)
} }

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