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

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# LNBits Admin UI
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# Backend Wallet Comparison Table
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ nav_order: 1
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
|
||||
|
||||
# Basic installation
|
||||
|
||||
@@ -51,7 +51,7 @@ nav_order: 1
|
||||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 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.
|
||||
@@ -285,7 +285,10 @@ but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true 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
|
||||
```
|
||||
|
||||
> 
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# LNbits Super User (SU)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 1
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# LNbits Roles: A Quick Overview
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ nav_order: 3
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# Backend wallets
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ from lnbits.core.tasks import (
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.llms_txt import create_llms_txt_route
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
@@ -66,7 +65,6 @@ from .middleware import (
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_profiler_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
@@ -103,9 +101,6 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# register llms.txt endpoint for AI agents
|
||||
create_llms_txt_route(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
@@ -201,9 +196,6 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
if settings.profiler:
|
||||
add_profiler_middleware(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from .extensions import (
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_installed_extensions_count,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
@@ -59,7 +58,6 @@ from .users import (
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_accounts_count,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
@@ -119,13 +117,11 @@ __all__ = [
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_accounts_count",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_installed_extensions_count",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
|
||||
@@ -90,13 +90,6 @@ async def get_installed_extensions(
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM installed_extensions"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
|
||||
@@ -290,10 +290,8 @@ async def create_payment(
|
||||
webhook=data.webhook,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extension=data.extension,
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -307,7 +305,7 @@ async def update_payment_checking_id(
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE checking_id = :old_id
|
||||
""", # noqa: S608
|
||||
{
|
||||
@@ -322,15 +320,13 @@ async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
) -> None:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
)
|
||||
if new_checking_id and new_checking_id != payment.checking_id:
|
||||
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
|
||||
payment.checking_id = new_checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
@@ -402,6 +398,7 @@ async def get_payment_count_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
@@ -434,6 +431,7 @@ async def get_daily_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -483,6 +481,7 @@ async def get_wallets_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
||||
@@ -8,18 +8,11 @@ from lnbits.db import dict_to_model
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
FundingSourcesSettings,
|
||||
SettingsField,
|
||||
SuperSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
RESET_PRESERVED_SETTINGS = (
|
||||
"lnbits_webpush_pubkey",
|
||||
"lnbits_webpush_privkey",
|
||||
*FundingSourcesSettings.__fields__,
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> SuperSettings | None:
|
||||
data = await get_settings_by_tag("core")
|
||||
@@ -76,14 +69,16 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
core_settings = await get_settings_by_tag("core") or {}
|
||||
super_user = await get_settings_field("super_user")
|
||||
await delete_admin_settings()
|
||||
if super_user:
|
||||
await set_settings_field("super_user", super_user.value)
|
||||
for field in RESET_PRESERVED_SETTINGS:
|
||||
if field in core_settings:
|
||||
await set_settings_field(field, core_settings[field])
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
|
||||
@@ -37,13 +37,6 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def get_accounts_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM accounts"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def update_account(account: Account, conn: Connection | None = None) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("accounts", account)
|
||||
|
||||
@@ -802,16 +802,3 @@ async def m044_add_activated_to_accounts(db: Connection):
|
||||
Used for account activation status.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
|
||||
|
||||
|
||||
async def m045_add_external_id_to_payments(db: Connection):
|
||||
"""
|
||||
Adds external_id column to apipayments.
|
||||
Used for external payment references.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
|
||||
logger.debug("Creating index idx_payments_external_id...")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_external_id
|
||||
ON apipayments (external_id);
|
||||
""")
|
||||
|
||||
@@ -25,7 +25,6 @@ from .payments import (
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -91,7 +90,6 @@ __all__ = [
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdatePaymentExtra",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
|
||||
@@ -55,10 +55,9 @@ class GitHubRelease(BaseModel):
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
featured: list[str] = []
|
||||
categories: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
@@ -309,6 +308,7 @@ class ExtensionRelease(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
@@ -333,7 +333,6 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
categories: list[str] = []
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
@@ -681,11 +680,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
|
||||
@@ -701,11 +695,6 @@ class InstallableExtension(BaseModel):
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,7 +13,6 @@ from lnbits.db import FilterModel
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentStatus,
|
||||
)
|
||||
from lnbits.helpers import is_valid_external_id
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets.base import (
|
||||
PaymentStatus,
|
||||
@@ -35,11 +34,6 @@ class PaymentExtra(BaseModel):
|
||||
lnurl_response: str | None = None
|
||||
|
||||
|
||||
class UpdatePaymentExtra(BaseModel):
|
||||
payment_hash: str
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: str | None = None
|
||||
@@ -54,17 +48,11 @@ class CreatePayment(BaseModel):
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: dict | None = {}
|
||||
extension: str | None = None
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -89,11 +77,6 @@ class Payment(BaseModel):
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
@@ -141,18 +124,22 @@ class Payment(BaseModel):
|
||||
)
|
||||
|
||||
# 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.")
|
||||
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
|
||||
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.")
|
||||
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):
|
||||
@@ -164,7 +151,6 @@ class PaymentFilters(FilterModel):
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
__sort_fields__ = [
|
||||
@@ -175,13 +161,11 @@ class PaymentFilters(FilterModel):
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
checking_id: str | None
|
||||
external_id: str | None
|
||||
amount: int
|
||||
fee: int
|
||||
memo: str | None
|
||||
@@ -260,13 +244,11 @@ class CreateInvoice(BaseModel):
|
||||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
extension: str | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
@@ -281,10 +263,6 @@ class CreateInvoice(BaseModel):
|
||||
raise ValueError("The provided unit is not supported")
|
||||
return v
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class PaymentsStatusCount(BaseModel):
|
||||
incoming: int = 0
|
||||
@@ -323,12 +301,3 @@ class CancelInvoice(BaseModel):
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
def _validate_external_id(external_id: str | None) -> str | None:
|
||||
if external_id and not is_valid_external_id(external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
return external_id
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import base64
|
||||
import io
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import filetype
|
||||
from fastapi import UploadFile
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
@@ -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.settings import settings
|
||||
|
||||
IMAGE_MIME_TYPE_ALIASES = {
|
||||
"heic": "image/heic",
|
||||
"heics": "image/heics",
|
||||
"heif": "image/heif",
|
||||
"image/jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"jpg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
PIL_IMAGE_FORMAT_MIME_TYPES = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
}
|
||||
INLINE_ASSET_MIME_TYPES = {
|
||||
"image/heic",
|
||||
"image/heics",
|
||||
"image/heif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
}
|
||||
ASSET_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": (
|
||||
"sandbox; default-src 'none'; script-src 'none'; "
|
||||
"object-src 'none'; base-uri 'none'"
|
||||
),
|
||||
}
|
||||
THUMBNAIL_FORMAT_MIME_TYPES = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
|
||||
|
||||
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
|
||||
if not file.content_type:
|
||||
raise ValueError("File must have a content type.")
|
||||
|
||||
content_type = normalize_asset_mime_type(file.content_type)
|
||||
filename = file.filename or "unnamed"
|
||||
|
||||
if content_type not in allowed_asset_mime_types():
|
||||
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
|
||||
raise ValueError(f"File type '{file.content_type}' not allowed.")
|
||||
|
||||
if not settings.is_unlimited_assets_user(user_id):
|
||||
@@ -69,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."
|
||||
)
|
||||
|
||||
stored_mime_type = detect_image_mime_type(contents)
|
||||
if stored_mime_type != content_type:
|
||||
logger.warning(
|
||||
"Image MIME type mismatch: declared={}, detected={}",
|
||||
content_type,
|
||||
stored_mime_type,
|
||||
)
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type. "
|
||||
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
|
||||
)
|
||||
|
||||
thumb_buffer = thumbnail_from_bytes(contents)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
mime_type=stored_mime_type,
|
||||
mime_type=file.content_type,
|
||||
is_public=is_public,
|
||||
name=filename,
|
||||
name=file.filename or "unnamed",
|
||||
size_bytes=len(contents),
|
||||
thumbnail_base64=(
|
||||
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
|
||||
@@ -102,79 +51,6 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
||||
return asset
|
||||
|
||||
|
||||
def normalize_asset_mime_type(content_type: str) -> str:
|
||||
content_type = content_type.split(";", 1)[0].strip().lower()
|
||||
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
|
||||
|
||||
|
||||
def normalize_media_type(media_type: str) -> str:
|
||||
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
|
||||
|
||||
|
||||
def thumbnail_media_type() -> str:
|
||||
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
|
||||
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
safe_filename = filename or "unnamed"
|
||||
quoted_filename = quote(safe_filename, safe="")
|
||||
if quoted_filename == safe_filename:
|
||||
return f'{disposition}; filename="{safe_filename}"'
|
||||
return f"{disposition}; filename*=utf-8''{quoted_filename}"
|
||||
|
||||
|
||||
def allowed_asset_mime_types() -> set[str]:
|
||||
return {
|
||||
mime_type
|
||||
for mime_type in (
|
||||
normalize_asset_mime_type(mime_type)
|
||||
for mime_type in settings.lnbits_assets_allowed_mime_types
|
||||
)
|
||||
if mime_type.startswith("image/")
|
||||
}
|
||||
|
||||
|
||||
def detect_image_mime_type(contents: bytes) -> str:
|
||||
kind = filetype.guess(contents)
|
||||
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
|
||||
|
||||
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
|
||||
verify_pil_image(contents, mime_type)
|
||||
return mime_type
|
||||
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
return mime_type
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if not mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
return mime_type
|
||||
|
||||
|
||||
def verify_pil_image(contents: bytes, mime_type: str) -> None:
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if detected_mime_type != mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
|
||||
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
|
||||
try:
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
|
||||
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
get_installed_extensions_count,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
@@ -39,8 +38,6 @@ async def install_extension(
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
@@ -66,15 +63,6 @@ async def install_extension(
|
||||
return extension
|
||||
|
||||
|
||||
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
|
||||
if settings.lnbits_max_extensions == 0 or installed_ext:
|
||||
return
|
||||
|
||||
extensions_count = await get_installed_extensions_count()
|
||||
if extensions_count >= settings.lnbits_max_extensions:
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment, update_payment
|
||||
from lnbits.core.crud.payments import create_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
@@ -36,7 +35,9 @@ async def handle_fiat_payment_confirmation(
|
||||
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:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment.success:
|
||||
@@ -56,11 +57,10 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if fiat_status.success:
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
@@ -169,82 +169,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
||||
raise ValueError("PayPal webhook cannot be verified.") from exc
|
||||
|
||||
|
||||
def check_square_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
notification_url: str | None,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Square signature header is missing.")
|
||||
raise ValueError("Square signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Square webhook signature key is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
if not notification_url:
|
||||
logger.warning("Square webhook notification URL is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
signed_payload = notification_url.encode() + payload
|
||||
computed_signature = b64encode(
|
||||
hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
if hmac.compare_digest(computed_signature, sig_header) is not True:
|
||||
logger.warning("Square signature verification failed.")
|
||||
raise ValueError("Square signature verification failed.")
|
||||
|
||||
|
||||
def check_revolut_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
timestamp_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Revolut signature header is missing.")
|
||||
raise ValueError("Revolut signature header is missing.")
|
||||
|
||||
if not timestamp_header:
|
||||
logger.warning("Revolut timestamp header is missing.")
|
||||
raise ValueError("Revolut timestamp header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Revolut webhook signing secret is not set.")
|
||||
raise ValueError("Revolut webhook cannot be verified.")
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp_header)
|
||||
except ValueError as exc:
|
||||
logger.warning("Invalid Revolut timestamp.")
|
||||
raise ValueError("Invalid Revolut timestamp.") from exc
|
||||
|
||||
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
|
||||
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
|
||||
digest = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
expected_signature = f"v1={digest}"
|
||||
|
||||
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
|
||||
if not any(
|
||||
hmac.compare_digest(expected_signature, provided)
|
||||
for provided in provided_signatures
|
||||
):
|
||||
logger.warning("Revolut signature verification failed.")
|
||||
raise ValueError("Revolut signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
|
||||
@@ -74,7 +74,7 @@ async def send_admin_notification(
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
return await send_notification_in_background(
|
||||
return await send_notification(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
@@ -97,7 +97,7 @@ async def send_user_notification(
|
||||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification_in_background(
|
||||
return await send_notification(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
@@ -222,20 +222,12 @@ async def send_email(
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
username = username if len(username) > 0 else from_email
|
||||
|
||||
def _send() -> bool:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_send)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sending Email failed. {e!s}")
|
||||
return False
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
@@ -302,27 +294,6 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
||||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_notification_in_background(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
create_task(
|
||||
send_notification(
|
||||
telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_addresses,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending notification in background: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
|
||||
@@ -13,6 +13,7 @@ from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
@@ -63,7 +64,6 @@ async def pay_invoice(
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
@@ -97,7 +97,6 @@ async def pay_invoice(
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
@@ -170,15 +169,15 @@ async def create_fiat_invoice(
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# TODO: move to payment
|
||||
# todo: move to payent
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
internal_payment = await update_payment(
|
||||
internal_payment, new_checking_id, conn=conn
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
return internal_payment
|
||||
|
||||
|
||||
@@ -214,12 +213,10 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
extension=data.extension,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
external_id=data.external_id,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
@@ -260,9 +257,7 @@ async def create_invoice(
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
extension: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -344,11 +339,9 @@ async def create_invoice(
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
extension=extension,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -376,7 +369,7 @@ async def update_pending_payment(
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
@@ -632,14 +625,18 @@ async def check_transaction_status(
|
||||
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.success:
|
||||
return PaymentSuccessStatus()
|
||||
if payment.failed:
|
||||
return PaymentFailedStatus()
|
||||
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 PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
@@ -781,14 +778,9 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await _send_payment_notification_in_background(
|
||||
wallet.id, payment, conn=conn
|
||||
) # notify the sender
|
||||
await _send_payment_notification_in_background(
|
||||
internal_payment.wallet_id, internal_payment, conn=conn
|
||||
) # notify the receiver
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
@@ -879,7 +871,7 @@ async def update_payment_success_status(
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -1082,29 +1074,28 @@ async def _send_payment_notification_in_background(
|
||||
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()
|
||||
Checks its status, updates its status and returns it.
|
||||
returns None if no incoming payment was found or the status is not successful
|
||||
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
|
||||
or internal_invoice_queue. Checks its status, updates and returns it.
|
||||
returns None if no payment was found or it not and incoming payment.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No incoming payment found for '{checking_id}'.")
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
|
||||
if not status.success:
|
||||
logger.error(
|
||||
"Unexpected status response from paid_invoices_stream. Skipping update."
|
||||
)
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment = await update_payment(payment)
|
||||
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
return payment
|
||||
|
||||
@@ -23,7 +23,6 @@ from ..crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_accounts_count,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
await check_users_limit(conn)
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(
|
||||
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
|
||||
return user
|
||||
|
||||
|
||||
async def check_users_limit(conn: Connection | None = None):
|
||||
if settings.lnbits_max_users == 0:
|
||||
return
|
||||
|
||||
users_count = await get_accounts_count(conn=conn)
|
||||
if users_count >= settings.lnbits_max_users:
|
||||
raise ValueError("Max amount of users have been created")
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ from lnbits.core.services.notifications import (
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
@@ -151,34 +150,12 @@ async def collect_exchange_rates_data() -> None:
|
||||
|
||||
if sleep_time > 0:
|
||||
try:
|
||||
if (
|
||||
settings.lnbits_price_aggregator_enabled
|
||||
and settings.lnbits_price_aggregator_url
|
||||
):
|
||||
price = await btc_price_from_aggregator(currency)
|
||||
if price:
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
price,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(
|
||||
{"Aggregator": price}, max_history_size
|
||||
)
|
||||
else:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
lnbits_rate,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(
|
||||
dict(rates), max_history_size
|
||||
)
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
else:
|
||||
|
||||
@@ -16,14 +16,7 @@ from lnbits.core.crud.assets import (
|
||||
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.services.assets import (
|
||||
ASSET_SECURITY_HEADERS,
|
||||
INLINE_ASSET_MIME_TYPES,
|
||||
content_disposition,
|
||||
create_user_asset,
|
||||
normalize_media_type,
|
||||
thumbnail_media_type,
|
||||
)
|
||||
from lnbits.core.services.assets import create_user_asset
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
@@ -82,7 +75,11 @@ async def api_get_asset_data(
|
||||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return asset_response(asset.data, asset.mime_type, asset.name)
|
||||
return Response(
|
||||
content=asset.data,
|
||||
media_type=asset.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return asset_response(
|
||||
return Response(
|
||||
content=(
|
||||
base64.b64decode(asset_info.thumbnail_base64)
|
||||
if asset_info.thumbnail_base64
|
||||
else b""
|
||||
),
|
||||
media_type=thumbnail_media_type(),
|
||||
filename=asset_info.name,
|
||||
media_type=asset_info.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@@ -175,16 +172,3 @@ async def api_delete_asset(
|
||||
|
||||
await delete_user_asset(account_id.id, asset_id)
|
||||
return SimpleStatus(success=True, message="Asset deleted successfully.")
|
||||
|
||||
|
||||
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
|
||||
media_type = normalize_media_type(media_type)
|
||||
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
**ASSET_SECURITY_HEADERS,
|
||||
"Content-Disposition": content_disposition(disposition, filename),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
@@ -321,10 +320,7 @@ async def api_delete_user_api_token(
|
||||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request,
|
||||
provider: str,
|
||||
user_id: str | None,
|
||||
auth_user_id: str | None = Depends(optional_user_id),
|
||||
request: Request, provider: str, user_id: str | None = None
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
@@ -332,8 +328,6 @@ async def login_with_sso_provider(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
if user_id and user_id != auth_user_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
|
||||
|
||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||
with provider_sso:
|
||||
@@ -354,11 +348,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
userinfo = await provider_sso.verify_and_process(request)
|
||||
if not userinfo:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
|
||||
if provider_sso.state is None or provider_sso.state == "null":
|
||||
user_id = None
|
||||
else:
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
|
||||
|
||||
@@ -4,30 +4,17 @@ from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentFilters
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_fiat_status,
|
||||
check_revolut_signature,
|
||||
check_square_signature,
|
||||
check_stripe_signature,
|
||||
verify_paypal_webhook,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
create_fiat_invoice,
|
||||
create_wallet_invoice,
|
||||
service_fee_fiat,
|
||||
)
|
||||
from lnbits.db import Filter, Filters
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.core.services.payments import create_fiat_invoice
|
||||
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
|
||||
from lnbits.fiat.revolut import RevolutWallet
|
||||
from lnbits.fiat.square import SquareWallet
|
||||
from lnbits.settings import settings
|
||||
|
||||
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
||||
@@ -63,41 +50,6 @@ async def api_generic_webhook_handler(
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "square":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("x-square-hmacsha256-signature")
|
||||
check_square_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
settings.square_webhook_signature_key,
|
||||
settings.square_payment_webhook_url,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_square_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "revolut":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("Revolut-Signature")
|
||||
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
|
||||
check_revolut_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
timestamp_header,
|
||||
settings.revolut_webhook_signing_secret,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_revolut_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Unknown fiat provider '{provider_name}'.",
|
||||
@@ -328,382 +280,3 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
|
||||
except (json.JSONDecodeError, IndexError) as e:
|
||||
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
|
||||
|
||||
async def handle_square_event(event: dict):
|
||||
event_id = event.get("event_id") or event.get("id", "")
|
||||
event_type = event.get("type", "")
|
||||
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
|
||||
|
||||
if event_type == "payment.updated":
|
||||
await _handle_square_payment_event(event)
|
||||
return
|
||||
|
||||
if event_type == "invoice.payment_made":
|
||||
await _handle_square_invoice_payment_made(event)
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Square event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def handle_revolut_event(event: dict):
|
||||
event_type = event.get("event", "")
|
||||
order_id = event.get("order_id")
|
||||
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
|
||||
|
||||
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
|
||||
if not order_id:
|
||||
logger.warning("Revolut event missing order_id.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if payment:
|
||||
await check_fiat_status(payment)
|
||||
return
|
||||
|
||||
if event_type == "ORDER_COMPLETED":
|
||||
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
|
||||
await _handle_revolut_subscription_order_paid(order_id)
|
||||
return
|
||||
|
||||
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
if event_type == "SUBSCRIPTION_INITIATED":
|
||||
logger.info("Revolut subscription initiated event received.")
|
||||
return
|
||||
|
||||
if event_type in [
|
||||
"SUBSCRIPTION_CANCELLED",
|
||||
"SUBSCRIPTION_FINISHED",
|
||||
"SUBSCRIPTION_OVERDUE",
|
||||
]:
|
||||
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def _get_revolut_provider() -> RevolutWallet | None:
|
||||
fiat_provider = await get_fiat_provider("revolut")
|
||||
if not isinstance(fiat_provider, RevolutWallet):
|
||||
logger.warning("Revolut fiat provider is not configured.")
|
||||
return None
|
||||
return fiat_provider
|
||||
|
||||
|
||||
async def _handle_revolut_subscription(
|
||||
subscription: dict,
|
||||
fiat_provider: RevolutWallet,
|
||||
order_id: str | None = None,
|
||||
order: dict | None = None,
|
||||
):
|
||||
subscription_id = subscription.get("id")
|
||||
if not subscription_id:
|
||||
logger.warning("Revolut subscription missing id.")
|
||||
return
|
||||
|
||||
reference = fiat_provider.deserialize_subscription_reference(
|
||||
subscription.get("external_reference")
|
||||
)
|
||||
if not reference:
|
||||
logger.warning("Revolut subscription event missing LNbits metadata.")
|
||||
return
|
||||
|
||||
if not order_id:
|
||||
cycle_id = subscription.get("current_cycle_id")
|
||||
if not cycle_id:
|
||||
logger.warning("Revolut subscription missing current_cycle_id.")
|
||||
return
|
||||
|
||||
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
|
||||
order_id = cycle.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Revolut subscription cycle missing order_id.")
|
||||
return
|
||||
|
||||
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if existing_payment:
|
||||
if existing_payment.external_id != subscription_id:
|
||||
existing_payment.external_id = subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
if not order:
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
amount_minor = order.get("amount")
|
||||
currency = (order.get("currency") or "").upper()
|
||||
if amount_minor is None or not currency:
|
||||
raise ValueError("Revolut subscription order missing amount or currency.")
|
||||
|
||||
extra = {
|
||||
**(reference.extra or {}),
|
||||
"subscription_request_id": subscription_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": reference.tag,
|
||||
"subscription": {
|
||||
"checking_id": f"order_{order_id}",
|
||||
"payment_request": order.get("checkout_url") or "",
|
||||
},
|
||||
}
|
||||
lnbits_payment = await _create_revolut_subscription_payment(
|
||||
wallet_id=reference.wallet_id,
|
||||
amount_minor=amount_minor,
|
||||
currency=currency,
|
||||
memo=reference.memo or "",
|
||||
extra=extra,
|
||||
order_id=order_id,
|
||||
payment_request=order.get("checkout_url") or "",
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_revolut_subscription_order_paid(order_id: str):
|
||||
fiat_provider = await _get_revolut_provider()
|
||||
if not fiat_provider:
|
||||
return
|
||||
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
order_type = (order.get("type") or "").lower()
|
||||
order_state = (order.get("state") or "").upper()
|
||||
if order_type != "payment" or order_state != "COMPLETED":
|
||||
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
channel_data = order.get("channel_data") or {}
|
||||
subscription_id = channel_data.get("subscription_id")
|
||||
if not subscription_id:
|
||||
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
|
||||
return
|
||||
|
||||
subscription = await fiat_provider.get_subscription(subscription_id)
|
||||
if subscription.get("state") != "active":
|
||||
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
|
||||
return
|
||||
|
||||
await _handle_revolut_subscription(
|
||||
subscription, fiat_provider, order_id=order_id, order=order
|
||||
)
|
||||
|
||||
|
||||
async def _create_revolut_subscription_payment(
|
||||
wallet_id: str,
|
||||
amount_minor: int,
|
||||
currency: str,
|
||||
memo: str,
|
||||
extra: dict,
|
||||
order_id: str,
|
||||
payment_request: str,
|
||||
subscription_id: str,
|
||||
) -> Payment:
|
||||
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
|
||||
payment = await create_wallet_invoice(
|
||||
wallet_id,
|
||||
CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
internal=True,
|
||||
external_id=subscription_id,
|
||||
),
|
||||
)
|
||||
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
|
||||
payment.fiat_provider = "revolut"
|
||||
payment.extra["fiat_checking_id"] = f"order_{order_id}"
|
||||
payment.extra["fiat_payment_request"] = payment_request
|
||||
checking_id = f"fiat_revolut_order_{order_id}"
|
||||
await update_payment(payment, checking_id)
|
||||
payment.checking_id = checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def _handle_square_payment_event(event: dict):
|
||||
payment = _square_extract_payment(event)
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if payment_options.wallet_id:
|
||||
if not _square_payment_is_completed(payment):
|
||||
logger.debug("Square subscription payment is not completed yet.")
|
||||
return
|
||||
await _handle_square_subscription_payment(payment, payment_options)
|
||||
return
|
||||
|
||||
order_id = payment.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square payment event missing order_id.")
|
||||
return
|
||||
|
||||
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
|
||||
if not lnbits_payment:
|
||||
logger.warning(f"No payment found for Square order: '{order_id}'.")
|
||||
return
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_square_invoice_payment_made(event: dict):
|
||||
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
|
||||
order_id = invoice.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square invoice.payment_made event missing order_id.")
|
||||
return
|
||||
subscription_id = invoice.get("subscription_id")
|
||||
|
||||
fiat_provider = await get_fiat_provider("square")
|
||||
if not isinstance(fiat_provider, SquareWallet):
|
||||
logger.warning("Square fiat provider is not configured.")
|
||||
return
|
||||
|
||||
payment = await fiat_provider.get_payment_for_order(order_id)
|
||||
if not payment:
|
||||
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
|
||||
return
|
||||
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if not payment_options.wallet_id:
|
||||
payment_id = payment.get("id")
|
||||
stored_payment = (
|
||||
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
|
||||
if payment_id
|
||||
else None
|
||||
)
|
||||
if not stored_payment and subscription_id:
|
||||
stored_payments = await get_payments(
|
||||
filters=Filters(
|
||||
filters=[
|
||||
Filter.parse_query(
|
||||
"external_id", [subscription_id], PaymentFilters
|
||||
)
|
||||
],
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
stored_payment = stored_payments[0] if stored_payments else None
|
||||
if stored_payment:
|
||||
payment_options = _square_payment_options_from_payment(stored_payment)
|
||||
else:
|
||||
logger.warning("Square subscription payment missing LNbits metadata.")
|
||||
return
|
||||
|
||||
await _handle_square_subscription_payment(
|
||||
payment,
|
||||
payment_options,
|
||||
invoice.get("public_url") or "",
|
||||
square_subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_square_subscription_payment(
|
||||
payment: dict,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
payment_request: str = "",
|
||||
square_subscription_id: str | None = None,
|
||||
):
|
||||
amount_money = payment.get("amount_money") or {}
|
||||
amount = amount_money.get("amount")
|
||||
currency = (amount_money.get("currency") or "").upper()
|
||||
payment_id = payment.get("id")
|
||||
if amount is None or not currency or not payment_id:
|
||||
raise ValueError("Square subscription payment event missing payment amount.")
|
||||
wallet_id = payment_options.wallet_id
|
||||
if not wallet_id:
|
||||
raise ValueError("Square subscription payment event missing wallet_id.")
|
||||
|
||||
checking_id = f"payment_{payment_id}"
|
||||
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
|
||||
if existing_payment:
|
||||
if (
|
||||
square_subscription_id
|
||||
and existing_payment.external_id != square_subscription_id
|
||||
):
|
||||
existing_payment.external_id = square_subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
square_subscription_id = square_subscription_id or (
|
||||
payment_options.extra or {}
|
||||
).get("square_subscription_id")
|
||||
extra = {
|
||||
**(payment_options.extra or {}),
|
||||
"subscription_request_id": payment_options.subscription_request_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": payment_options.tag,
|
||||
"subscription": {
|
||||
"checking_id": checking_id,
|
||||
"payment_request": payment_request,
|
||||
},
|
||||
}
|
||||
|
||||
lnbits_payment = await create_fiat_invoice(
|
||||
wallet_id=wallet_id,
|
||||
invoice_data=CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount / 100,
|
||||
memo=payment_options.memo or "",
|
||||
extra=extra,
|
||||
fiat_provider="square",
|
||||
external_id=square_subscription_id,
|
||||
),
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
def _square_payment_options_from_payment(
|
||||
payment: Payment,
|
||||
) -> FiatSubscriptionPaymentOptions:
|
||||
extra = payment.extra or {}
|
||||
return FiatSubscriptionPaymentOptions(
|
||||
wallet_id=payment.wallet_id,
|
||||
tag=extra.get("tag") or payment.tag,
|
||||
subscription_request_id=extra.get("subscription_request_id"),
|
||||
extra=extra,
|
||||
memo=payment.memo,
|
||||
)
|
||||
|
||||
|
||||
def _square_extract_payment(event: dict) -> dict:
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
return event_object.get("payment") or event_object
|
||||
|
||||
|
||||
def _square_payment_is_completed(payment: dict) -> bool:
|
||||
return (payment.get("status") or "").upper() == "COMPLETED"
|
||||
|
||||
|
||||
def _square_payment_note(payment: dict) -> str:
|
||||
return payment.get("note") or payment.get("payment_note") or ""
|
||||
|
||||
|
||||
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
|
||||
try:
|
||||
meta = json.loads(custom_id)
|
||||
if not isinstance(meta, list):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
wallet_id = meta[0] if len(meta) > 0 else None
|
||||
tag = meta[1] if len(meta) > 1 else None
|
||||
subscription_request_id = meta[2] if len(meta) > 2 else None
|
||||
extra_link = meta[3] if len(meta) > 3 else None
|
||||
memo = meta[4] if len(meta) > 4 else None
|
||||
|
||||
extra = {
|
||||
"link": extra_link,
|
||||
"subscription_request_id": subscription_request_id,
|
||||
}
|
||||
|
||||
return FiatSubscriptionPaymentOptions(
|
||||
wallet_id=wallet_id,
|
||||
tag=tag,
|
||||
subscription_request_id=subscription_request_id,
|
||||
extra=extra,
|
||||
memo=memo,
|
||||
)
|
||||
except (json.JSONDecodeError, IndexError, TypeError):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
|
||||
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
if isinstance(exc, AssertionError)
|
||||
else f"Failed to install extension '{ext_info.id}'."
|
||||
f"({ext_info.installed_version})."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
HTTPStatus.BAD_REQUEST
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
@@ -292,6 +288,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise HTTPException(
|
||||
@@ -566,7 +563,6 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"categories": ext.meta.categories if ext.meta else [],
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
|
||||
@@ -2,35 +2,17 @@ from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.wallets import WalletTypeInfo
|
||||
from lnbits.core.services import update_cached_settings
|
||||
from lnbits.core.services.fiat_providers import test_connection
|
||||
from lnbits.decorators import check_admin, require_admin_key
|
||||
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
|
||||
|
||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||
|
||||
|
||||
class RevolutCreateWebhook(BaseModel):
|
||||
url: str
|
||||
endpoint: str | None = None
|
||||
api_secret_key: str | None = None
|
||||
api_version: str | None = None
|
||||
|
||||
|
||||
class RevolutCreateWebhookResponse(BaseModel):
|
||||
id: str | None = None
|
||||
url: str
|
||||
events: list[str] = []
|
||||
signing_secret: str
|
||||
already_exists: bool = False
|
||||
|
||||
|
||||
@fiat_router.put(
|
||||
"/check/{provider}",
|
||||
status_code=HTTPStatus.OK,
|
||||
@@ -40,54 +22,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||
return await test_connection(provider)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/revolut/webhook",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_create_revolut_webhook(
|
||||
data: RevolutCreateWebhook,
|
||||
) -> RevolutCreateWebhookResponse:
|
||||
try:
|
||||
webhook = await RevolutWallet.create_webhook(
|
||||
url=data.url,
|
||||
endpoint=data.endpoint,
|
||||
api_secret_key=data.api_secret_key,
|
||||
api_version=data.api_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Revolut webhook."
|
||||
) from exc
|
||||
|
||||
signing_secret = webhook.get("signing_secret")
|
||||
webhook_url = webhook.get("url") or data.url
|
||||
if not signing_secret:
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Revolut returned no webhook signing secret."
|
||||
)
|
||||
|
||||
updated_settings = {
|
||||
"revolut_payment_webhook_url": webhook_url,
|
||||
"revolut_webhook_signing_secret": signing_secret,
|
||||
}
|
||||
for key, value in updated_settings.items():
|
||||
await set_settings_field(key, value)
|
||||
update_cached_settings(updated_settings)
|
||||
|
||||
return RevolutCreateWebhookResponse(
|
||||
id=webhook.get("id"),
|
||||
url=webhook_url,
|
||||
events=webhook.get("events") or [],
|
||||
signing_secret=signing_secret,
|
||||
already_exists=webhook.get("already_exists", False),
|
||||
)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/{provider}/subscription",
|
||||
status_code=HTTPStatus.OK,
|
||||
|
||||
@@ -34,7 +34,6 @@ from lnbits.core.models import (
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
SimpleStatus,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
@@ -264,7 +263,6 @@ async def api_payments_create(
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
labels=invoice_data.labels,
|
||||
external_id=invoice_data.external_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -298,38 +296,6 @@ async def api_update_payment_labels(
|
||||
return SimpleStatus(success=True, message="Payment labels updated.")
|
||||
|
||||
|
||||
@payment_router.patch(
|
||||
"/extra",
|
||||
name="Update payment extra",
|
||||
description="Append new extra metadata to a payment.",
|
||||
response_model=Payment,
|
||||
)
|
||||
async def api_update_payment_extra(
|
||||
data: UpdatePaymentExtra,
|
||||
key_type: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Payment:
|
||||
payment = await get_standalone_payment(
|
||||
data.payment_hash, wallet_id=key_type.wallet.id
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
|
||||
if not payment.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
|
||||
)
|
||||
|
||||
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
|
||||
if duplicate_keys:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
|
||||
)
|
||||
|
||||
payment.extra.update(data.extra)
|
||||
await update_payment(payment)
|
||||
return payment
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
|
||||
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
|
||||
else:
|
||||
if not database_uri.startswith("postgres://"):
|
||||
raise ValueError(
|
||||
"Please use the 'postgres://...' format for the database URL."
|
||||
"Please use the 'postgres://...' " "format for the database URL."
|
||||
)
|
||||
DB_TYPE = POSTGRES
|
||||
|
||||
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def pagination(self) -> str:
|
||||
stmt = ""
|
||||
if self.limit == 0:
|
||||
self.limit = 1000
|
||||
self.limit = 10 if self.limit is None else self.limit
|
||||
self.limit = self.limit or 10
|
||||
stmt += f"LIMIT {min(1000, self.limit)} "
|
||||
if self.offset:
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
|
||||
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .paypal import PayPalWallet
|
||||
from .revolut import RevolutWallet
|
||||
from .square import SquareWallet
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
paypal = "PayPalWallet"
|
||||
square = "SquareWallet"
|
||||
revolut = "RevolutWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider | None:
|
||||
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
__all__ = [
|
||||
"PayPalWallet",
|
||||
"RevolutWallet",
|
||||
"SquareWallet",
|
||||
"StripeWallet",
|
||||
]
|
||||
|
||||
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
|
||||
description="Unique ID that can be used to identify the subscription request."
|
||||
"If not provided, one will be generated.",
|
||||
)
|
||||
customer_email: str | None = Field(
|
||||
default=None,
|
||||
description="The customer email to use for the subscription.",
|
||||
)
|
||||
tag: str | None = Field(
|
||||
default=None,
|
||||
description="Payments created by the recurring subscription"
|
||||
@@ -131,15 +127,15 @@ class FiatSubscriptionResponse(BaseModel):
|
||||
|
||||
|
||||
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||
paid = True # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = True
|
||||
|
||||
|
||||
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||
paid = False # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = False
|
||||
|
||||
|
||||
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||
paid = None # type: ignore[reportIncompatibleVariableOverride]
|
||||
paid = None
|
||||
|
||||
|
||||
class FiatProvider(ABC):
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from loguru import logger
|
||||
from pyinstrument import Profiler
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
|
||||
):
|
||||
return RedirectResponse("/first_install")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def add_profiler_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
async def profile_middleware(request: Request, call_next):
|
||||
profiling = request.query_params.get("profiler", False)
|
||||
if profiling:
|
||||
profiler = Profiler(async_mode="enabled")
|
||||
profiler.start()
|
||||
_ = await call_next(request)
|
||||
profiler.stop()
|
||||
return HTMLResponse(profiler.output_html())
|
||||
return await call_next(request)
|
||||
|
||||
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
@@ -300,7 +300,6 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_default_card_rounded: bool = Field(default=True)
|
||||
lnbits_default_card_gradient: bool = Field(default=True)
|
||||
lnbits_default_card_shadow: bool = Field(default=False)
|
||||
lnbits_default_burger_menu_background: bool = Field(default=True)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -324,6 +323,10 @@ class AssetSettings(LNbitsSettings):
|
||||
"heic",
|
||||
"heif",
|
||||
"heics",
|
||||
"text/plain",
|
||||
"text/json" "text/xml",
|
||||
"application/json",
|
||||
"application/pdf",
|
||||
]
|
||||
)
|
||||
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
|
||||
@@ -359,11 +362,9 @@ class FeeSettings(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_refresh_interval_seconds: int = Field(default=300, ge=0)
|
||||
lnbits_price_aggregator_enabled: bool = Field(default=True)
|
||||
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
|
||||
|
||||
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
|
||||
default=[
|
||||
@@ -587,9 +588,6 @@ class ZBDFundingSource(LNbitsSettings):
|
||||
class PhoenixdFundingSource(LNbitsSettings):
|
||||
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
|
||||
phoenixd_api_password: str | None = Field(default=None)
|
||||
phoenixd_data_dir: str | None = Field(default=None)
|
||||
phoenixd_mnemonic: str | None = Field(default=None)
|
||||
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
@@ -614,7 +612,6 @@ class SparkL2FundingSource(LNbitsSettings):
|
||||
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
|
||||
spark_l2_external_api_key: str | None = Field(default=None)
|
||||
spark_l2_mnemonic: str | None = Field(default=None)
|
||||
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
|
||||
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
|
||||
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
|
||||
@@ -652,7 +649,6 @@ class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
|
||||
|
||||
class StrikeFundingSource(LNbitsSettings):
|
||||
@@ -704,35 +700,6 @@ class PayPalFiatProvider(LNbitsSettings):
|
||||
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class SquareFiatProvider(LNbitsSettings):
|
||||
square_enabled: bool = Field(default=False)
|
||||
square_api_endpoint: str = Field(default="https://connect.squareup.com")
|
||||
square_access_token: str | None = Field(default=None)
|
||||
square_location_id: str | None = Field(default=None)
|
||||
square_api_version: str = Field(default="2026-01-22")
|
||||
square_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
square_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
|
||||
)
|
||||
square_webhook_signature_key: str | None = Field(default=None)
|
||||
|
||||
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class RevolutFiatProvider(LNbitsSettings):
|
||||
revolut_enabled: bool = Field(default=False)
|
||||
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
|
||||
revolut_api_secret_key: str | None = Field(default=None)
|
||||
revolut_api_version: str = Field(default="2026-04-20")
|
||||
revolut_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
revolut_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
|
||||
)
|
||||
revolut_webhook_signing_secret: str | None = Field(default=None)
|
||||
|
||||
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -770,12 +737,7 @@ class FundingSourcesSettings(
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
class FiatProvidersSettings(
|
||||
StripeFiatProvider,
|
||||
PayPalFiatProvider,
|
||||
SquareFiatProvider,
|
||||
RevolutFiatProvider,
|
||||
):
|
||||
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
@@ -786,10 +748,6 @@ class FiatProvidersSettings(
|
||||
return self.stripe_enabled
|
||||
if provider == "paypal":
|
||||
return self.paypal_enabled
|
||||
if provider == "square":
|
||||
return self.square_enabled
|
||||
if provider == "revolut":
|
||||
return self.revolut_enabled
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
@@ -809,18 +767,6 @@ class FiatProvidersSettings(
|
||||
):
|
||||
allowed_providers.append("paypal")
|
||||
|
||||
if self.square_enabled and (
|
||||
not self.square_limits.allowed_users
|
||||
or user_id in self.square_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("square")
|
||||
|
||||
if self.revolut_enabled and (
|
||||
not self.revolut_limits.allowed_users
|
||||
or user_id in self.revolut_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("revolut")
|
||||
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
@@ -1052,7 +998,7 @@ class EditableSettings(
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config(EditableSettings.Config):
|
||||
class Config:
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
@@ -1060,8 +1006,8 @@ class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
profiler: bool = Field(default=False)
|
||||
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
# Set to false for local/dev environments that run without TLS.
|
||||
auth_https_only: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000, gt=0)
|
||||
@@ -1080,8 +1026,6 @@ class EnvSettings(LNbitsSettings):
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
lnbits_max_users: int = Field(default=0, ge=0)
|
||||
lnbits_max_extensions: int = Field(default=0, ge=0)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
@@ -1203,11 +1147,11 @@ class ReadOnlySettings(
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback # type: ignore[assignment]
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
return (
|
||||
@@ -1287,7 +1231,6 @@ class PublicSettings(BaseModel):
|
||||
default_card_rounded: bool = Field(alias="defaultCardRounded")
|
||||
default_card_gradient: bool = Field(alias="defaultCardGradient")
|
||||
default_card_shadow: bool = Field(alias="defaultCardShadow")
|
||||
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
|
||||
denomination: str | None = Field()
|
||||
extensions: list[str] = Field()
|
||||
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
|
||||
@@ -1351,7 +1294,6 @@ class PublicSettings(BaseModel):
|
||||
defaultCardRounded=settings.lnbits_default_card_rounded,
|
||||
defaultCardGradient=settings.lnbits_default_card_gradient,
|
||||
defaultCardShadow=settings.lnbits_default_card_shadow,
|
||||
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
|
||||
denomination=settings.lnbits_denomination,
|
||||
extensions=list(settings.lnbits_installed_extensions_ids),
|
||||
allowedCurrencies=settings.lnbits_allowed_currencies,
|
||||
|
||||
@@ -212,15 +212,12 @@ body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
body.body--dark .q-header,
|
||||
body.body--dark .q-drawer {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: brightness(0.8);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
body.no-burger-background .q-drawer {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -184,7 +184,6 @@ window.localisation.en = {
|
||||
release_notes: 'Release Notes',
|
||||
activate_extension_details: 'Make extension available/unavailable for users',
|
||||
featured: 'Featured',
|
||||
categories: 'Categories',
|
||||
all: 'All',
|
||||
only_admins_can_install: '(Only admin accounts can install 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_stripe_description:
|
||||
'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',
|
||||
update: 'Update',
|
||||
update_available: 'Update {version} available!',
|
||||
@@ -285,8 +275,6 @@ window.localisation.en = {
|
||||
requires_server_restart:
|
||||
'Changing these settings requires a server restart to take effect.',
|
||||
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}.',
|
||||
notifications: 'Notifications',
|
||||
notifications_configure: 'Configure Notifications',
|
||||
@@ -491,8 +479,6 @@ window.localisation.en = {
|
||||
toggle_card_gradient: 'Toggle gradient on cards',
|
||||
card_shadow: 'Card Shadow',
|
||||
toggle_card_shadow: 'Toggle shadow on cards',
|
||||
burger_menu_background: 'Burger Menu Background',
|
||||
toggle_burger_menu_background: 'Toggle burger menu background',
|
||||
language: 'Language',
|
||||
assets: 'Assets',
|
||||
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_paypal_description:
|
||||
'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_hint:
|
||||
'The user will be redirected to this URL after the payment is successful',
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -452,9 +452,7 @@ window.app.component('username-password', {
|
||||
confirmationMethod: 'code',
|
||||
confirmationEmail: '',
|
||||
confirmationCode: this.invitationCode || '',
|
||||
showConfirmationCode: false,
|
||||
showPwd: false,
|
||||
showPwdRepeat: false
|
||||
showConfirmationCode: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -62,6 +62,12 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
mounted() {
|
||||
this.getExchangeRateHistory()
|
||||
},
|
||||
created() {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (hash === 'exchange_providers') {
|
||||
this.showExchangeProvidersTab(hash)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDefaultSetting(fieldName) {
|
||||
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
||||
@@ -121,21 +127,18 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
this.exchangeData.showTickerConversion = true
|
||||
},
|
||||
initExchangeChart(data) {
|
||||
if (this.exchangeRatesChart) {
|
||||
this.exchangeRatesChart.destroy()
|
||||
this.exchangeRatesChart = null
|
||||
}
|
||||
const xValues = data.map(d =>
|
||||
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
|
||||
)
|
||||
const exchanges = this.formData.lnbits_price_aggregator_enabled
|
||||
? [{name: 'Aggregator'}]
|
||||
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
|
||||
const exchanges = [
|
||||
...this.formData.lnbits_exchange_rate_providers,
|
||||
{name: 'LNbits'}
|
||||
]
|
||||
const datasets = exchanges.map(exchange => ({
|
||||
label: exchange.name,
|
||||
data: data.map(d => d.rates[exchange.name]),
|
||||
pointStyle: true,
|
||||
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
|
||||
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
|
||||
tension: 0.4
|
||||
}))
|
||||
this.exchangeRatesChart = new Chart(
|
||||
@@ -145,11 +148,7 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Bitcoin Price History'
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,9 +5,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
return {
|
||||
formAddStripeUser: '',
|
||||
formAddPaypalUser: '',
|
||||
formAddSquareUser: '',
|
||||
formAddRevolutUser: '',
|
||||
creatingRevolutWebhook: false,
|
||||
hideInputToggle: true
|
||||
}
|
||||
},
|
||||
@@ -23,12 +20,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
this.formData?.paypal_payment_webhook_url ||
|
||||
this.calculateWebhookUrl('paypal')
|
||||
)
|
||||
},
|
||||
revolutWebhookUrl() {
|
||||
return (
|
||||
this.formData?.revolut_payment_webhook_url ||
|
||||
this.calculateWebhookUrl('revolut')
|
||||
)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -67,8 +58,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
syncWebhookUrls() {
|
||||
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
|
||||
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
|
||||
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
|
||||
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
|
||||
},
|
||||
maybeSetWebhookUrl(fieldName, provider) {
|
||||
if (!this.formData) {
|
||||
@@ -88,47 +77,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
}
|
||||
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() {
|
||||
const addUser = this.formAddStripeUser || ''
|
||||
if (
|
||||
@@ -163,40 +111,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
this.formData.paypal_limits.allowed_users =
|
||||
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) {
|
||||
LNbits.api
|
||||
.request('PUT', `/api/v1/fiat/check/${providerName}`)
|
||||
@@ -210,48 +124,6 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
})
|
||||
})
|
||||
.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_api_endpoint: 'Endpoint',
|
||||
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
|
||||
}
|
||||
phoenixd_api_password: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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',
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
|
||||
]
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
methods: {}
|
||||
})
|
||||
|
||||
@@ -360,37 +360,18 @@ window.app.component('lnbits-payment-list', {
|
||||
paymentTableRowKey(row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
async exportCSV(detailed = false) {
|
||||
exportCSV(detailed = false) {
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
const pagination = this.paymentsTable.pagination
|
||||
const maxPages = 100
|
||||
const limit = 1000
|
||||
let payments = []
|
||||
|
||||
this.paymentsCSV.loading = true
|
||||
try {
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
LNbits.api.getPayments(this.wallet, params).then(response => {
|
||||
let payments = response.data.data.map(this.mapPayment)
|
||||
let columns = this.paymentsCSV.columns
|
||||
|
||||
if (detailed) {
|
||||
@@ -419,11 +400,7 @@ window.app.component('lnbits-payment-list', {
|
||||
payments,
|
||||
this.wallet.name + '-payments'
|
||||
)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.paymentsCSV.loading = false
|
||||
}
|
||||
})
|
||||
},
|
||||
addFilterTag() {
|
||||
if (!this.exportTagName) return
|
||||
|
||||
@@ -8,10 +8,6 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'lnurlp'
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -25,10 +21,7 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
if (this.tab == 'bech32') {
|
||||
const bytes = new TextEncoder().encode(this.url)
|
||||
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
||||
this.lnurl =
|
||||
this.href && this.href.trim() !== ''
|
||||
? `${this.href}?lightning=${bech32.toUpperCase()}`
|
||||
: `lightning:${bech32.toUpperCase()}`
|
||||
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
||||
} else if (this.tab == 'lud17') {
|
||||
if (this.url.startsWith('http://')) {
|
||||
this.lnurl = this.url.replace('http://', this.prefix + '://')
|
||||
|
||||
@@ -85,9 +85,6 @@ window.app.component('lnbits-qrcode', {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return false
|
||||
} else if (this.href && this.href.startsWith('http')) {
|
||||
window.open(this.href, '_blank')
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
async writeNfcTag() {
|
||||
|
||||
@@ -69,14 +69,6 @@ window.app.component('lnbits-theme', {
|
||||
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) {
|
||||
this.$q.localStorage.set('lnbits.mobileSimple', val)
|
||||
if (val === true) {
|
||||
@@ -158,9 +150,6 @@ window.app.component('lnbits-theme', {
|
||||
if (this.g.cardShadowChoice === true) {
|
||||
document.body.classList.add('card-shadow')
|
||||
}
|
||||
if (this.g.burgerMenuChoice !== true) {
|
||||
document.body.classList.add('no-burger-background')
|
||||
}
|
||||
if (this.g.bgimageChoice !== '') {
|
||||
document.body.classList.add('bg-image')
|
||||
document.body.style.setProperty(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
function eventReaction(amount) {
|
||||
localUrl = ''
|
||||
const reaction =
|
||||
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction
|
||||
if (!reaction || reaction.toLowerCase() === 'none') {
|
||||
reaction = localStorage.getItem('lnbits.reactions')
|
||||
if (!reaction || reaction === 'None') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (amount < 0) {
|
||||
return
|
||||
}
|
||||
if (typeof window[reaction] === 'function') {
|
||||
window[reaction]()
|
||||
reaction = localStorage.getItem('lnbits.reactions')
|
||||
if (reaction) {
|
||||
window[reaction.split('|')[1]]()
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -29,10 +29,6 @@ window.g = Vue.reactive({
|
||||
SETTINGS.defaultCardGradient
|
||||
),
|
||||
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
|
||||
burgerMenuChoice: localStore(
|
||||
'lnbits.burgerMenu',
|
||||
SETTINGS.defaultBurgerMenuBackground
|
||||
),
|
||||
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
|
||||
bgimageChoice: localStore(
|
||||
'lnbits.backgroundImage',
|
||||
|
||||
@@ -27,14 +27,6 @@ const DynamicComponent = {
|
||||
name: r.name,
|
||||
component: async () => {
|
||||
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)
|
||||
return window[r.name]
|
||||
}
|
||||
@@ -159,30 +151,6 @@ window.i18n = new VueI18n.createI18n({
|
||||
fallbackLocale: 'en',
|
||||
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({
|
||||
data() {
|
||||
|
||||
@@ -545,56 +545,31 @@ window.PageAccount = {
|
||||
if (file) {
|
||||
this.uploadAsset(file)
|
||||
}
|
||||
e.target.value = null
|
||||
},
|
||||
onBackgroundImageInput(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
this.uploadBackgroundImage(file)
|
||||
}
|
||||
e.target.value = null
|
||||
},
|
||||
async uploadAsset(
|
||||
file,
|
||||
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
|
||||
) {
|
||||
async uploadAsset(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/assets?public_asset=${isPublic}`,
|
||||
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
|
||||
null,
|
||||
formData,
|
||||
{
|
||||
headers: {'Content-Type': 'multipart/form-data'}
|
||||
}
|
||||
)
|
||||
if (notifySuccess) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Upload successful!',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Upload successful!',
|
||||
icon: null
|
||||
})
|
||||
await this.getUserAssets()
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(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) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this asset?')
|
||||
@@ -732,8 +707,7 @@ window.PageAccount = {
|
||||
darkChoice: this.g.settings.defaultDark,
|
||||
cardRoundedChoice: this.g.settings.defaultCardRounded,
|
||||
cardGradientChoice: this.g.settings.defaultCardGradient,
|
||||
cardShadowChoice: this.g.settings.defaultCardShadow,
|
||||
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
|
||||
cardShadowChoice: this.g.settings.defaultCardShadow
|
||||
}
|
||||
this.siteCustomisationChanged(defaults)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ window.PageExtensions = {
|
||||
tab: 'installed',
|
||||
manageExtensionTab: 'releases',
|
||||
filteredExtensions: [],
|
||||
categories: new Set(),
|
||||
updatableExtensions: [],
|
||||
showUninstallDialog: false,
|
||||
showManageExtensionDialog: false,
|
||||
@@ -107,10 +106,6 @@ window.PageExtensions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
|
||||
const isInSelectedCategory = extension =>
|
||||
extension.categories?.includes(tab) ?? false
|
||||
|
||||
this.filteredExtensions = this.extensions
|
||||
.filter(e => (tab === 'all' ? !e.isInstalled : true))
|
||||
.filter(e => (tab === 'installed' ? e.isInstalled : true))
|
||||
@@ -118,7 +113,6 @@ window.PageExtensions = {
|
||||
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
|
||||
)
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
|
||||
.filter(extensionNameContains(term))
|
||||
.map(e => ({
|
||||
...e,
|
||||
@@ -838,9 +832,6 @@ window.PageExtensions = {
|
||||
async fetchAllExtensions() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
|
||||
data.forEach(ext => {
|
||||
ext.categories?.forEach(category => this.categories.add(category))
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
|
||||
@@ -21,7 +21,7 @@ window.PageHome = {
|
||||
return (
|
||||
this.lnurl !== '' &&
|
||||
this.g.settings.allowRegister &&
|
||||
this.g.settings.authMethods.includes('user-id-only')
|
||||
'user-id-only' in this.g.settings.authMethods
|
||||
)
|
||||
},
|
||||
formatDescription() {
|
||||
|
||||
@@ -223,12 +223,6 @@ window.PageWallet = {
|
||||
if (data.tag === 'payRequest') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
this.receive.units = [
|
||||
'sats',
|
||||
...(this.g.allowedCurrencies.length > 0
|
||||
? this.g.allowedCurrencies
|
||||
: this.g.currencies)
|
||||
]
|
||||
} else if (data.tag === 'login') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.tag === 'withdrawRequest') {
|
||||
|
||||
@@ -323,20 +323,6 @@ window._lnbitsUtils = {
|
||||
converter.setOption('simpleLineBreaks', true)
|
||||
return converter.makeHtml(text)
|
||||
},
|
||||
_extI18nDirs: new Set(),
|
||||
_extI18nLoaded: {},
|
||||
loadExtI18n(dir, locale) {
|
||||
this._extI18nDirs.add(dir)
|
||||
const loaded = (this._extI18nLoaded[dir] ??= {})
|
||||
if (loaded[locale]) return loaded[locale]
|
||||
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
|
||||
if (locale !== 'en') {
|
||||
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
|
||||
return loaded['en']
|
||||
}
|
||||
})
|
||||
return loaded[locale]
|
||||
},
|
||||
async decryptLnurlPayAES(success_action, preimage) {
|
||||
let keyb = new Uint8Array(
|
||||
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
|
||||
|
||||
@@ -58,15 +58,11 @@ body.bg-image {
|
||||
}
|
||||
// transparent background for specific elements
|
||||
body.body--dark {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
.q-header,
|
||||
.q-drawer {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: brightness(0.8);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +61,12 @@ body.rounded-ui {
|
||||
|
||||
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) {
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
body.no-burger-background {
|
||||
.q-drawer {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"js/pages/users.js",
|
||||
"js/pages/account.js",
|
||||
"js/pages/admin.js",
|
||||
"js/components/admin/lnbits-admin-funding-seed-backup.js",
|
||||
"js/components/admin/lnbits-admin-funding.js",
|
||||
"js/components/admin/lnbits-admin-funding-sources.js",
|
||||
"js/components/admin/lnbits-admin-fiat-providers.js",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* DOM element rendering detection
|
||||
* https://davidwalsh.name/detect-node-insertion
|
||||
*/
|
||||
@keyframes chartjs-render-animation {
|
||||
from { opacity: 0.99; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.chartjs-render-monitor {
|
||||
animation: chartjs-render-animation 0.001s;
|
||||
}
|
||||
|
||||
/*
|
||||
* DOM element resizing detection
|
||||
* https://github.com/marcj/css-element-queries
|
||||
*/
|
||||
.chartjs-size-monitor,
|
||||
.chartjs-size-monitor-expand,
|
||||
.chartjs-size-monitor-shrink {
|
||||
position: absolute;
|
||||
direction: ltr;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.chartjs-size-monitor-expand > div {
|
||||
position: absolute;
|
||||
width: 1000000px;
|
||||
height: 1000000px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.chartjs-size-monitor-shrink > div {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*!
|
||||
* qrcode.vue v3.9.0
|
||||
* qrcode.vue v3.6.0
|
||||
* A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3
|
||||
* © 2017-PRESENT @scopewu(https://github.com/scopewu)
|
||||
* MIT License.
|
||||
@@ -909,18 +909,7 @@ var qrcodegen;
|
||||
})(qrcodegen || (qrcodegen = {}));
|
||||
var QR = qrcodegen;
|
||||
|
||||
var _uid = 0;
|
||||
function getUid() {
|
||||
if (typeof vue.useId === 'function') {
|
||||
return "".concat(vue.useId(), "-").concat(_uid++);
|
||||
}
|
||||
return "vue-".concat(Math.random().toString(36).slice(2), "-").concat(_uid++);
|
||||
}
|
||||
var defaultErrorCorrectLevel = 'L';
|
||||
var DEFAULT_QR_SIZE = 100;
|
||||
var DEFAULT_MARGIN = 0;
|
||||
var DEFAULT_IMAGE_SIZE_RATIO = 0.1;
|
||||
var IMAGE_EXCAVATE_THICKNESS = 2;
|
||||
var ErrorCorrectLevelMap = {
|
||||
L: QR.QrCode.Ecc.LOW,
|
||||
M: QR.QrCode.Ecc.MEDIUM,
|
||||
@@ -940,139 +929,74 @@ var SUPPORTS_PATH2D = (function () {
|
||||
function validErrorCorrectLevel(level) {
|
||||
return level in ErrorCorrectLevelMap;
|
||||
}
|
||||
function getNeighborFlags(modules, row, col) {
|
||||
var north = row > 0 ? modules[row - 1][col] : false;
|
||||
var south = row < modules.length - 1 ? modules[row + 1][col] : false;
|
||||
var west = col > 0 ? modules[row][col - 1] : false;
|
||||
var east = col < modules[row].length - 1 ? modules[row][col + 1] : false;
|
||||
return {
|
||||
nw: !north && !west,
|
||||
ne: !north && !east,
|
||||
se: !south && !east,
|
||||
sw: !south && !west,
|
||||
};
|
||||
}
|
||||
function generateRoundedPath(modules, margin, radius) {
|
||||
if (margin === void 0) { margin = 0; }
|
||||
if (radius === void 0) { radius = 0; }
|
||||
var pathSegments = [];
|
||||
var r = Math.min(radius, 0.5);
|
||||
for (var row = 0; row < modules.length; row++) {
|
||||
for (var col = 0; col < modules[row].length; col++) {
|
||||
if (!modules[row][col])
|
||||
continue;
|
||||
var _a = getNeighborFlags(modules, row, col), nw = _a.nw, ne = _a.ne, se = _a.se, sw = _a.sw;
|
||||
var x = col + margin;
|
||||
var y = row + margin;
|
||||
pathSegments.push("M".concat(x + (nw ? r : 0), " ").concat(y), "L".concat(x + 1 - (ne ? r : 0), " ").concat(y));
|
||||
if (ne) {
|
||||
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1, " ").concat(y + r));
|
||||
}
|
||||
pathSegments.push("L".concat(x + 1, " ").concat(y + 1 - (se ? r : 0)));
|
||||
if (se) {
|
||||
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1 - r, " ").concat(y + 1));
|
||||
}
|
||||
pathSegments.push("L".concat(x + (sw ? r : 0), " ").concat(y + 1));
|
||||
if (sw) {
|
||||
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x, " ").concat(y + 1 - r));
|
||||
}
|
||||
pathSegments.push("L".concat(x, " ").concat(y + (nw ? r : 0)));
|
||||
if (nw) {
|
||||
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + r, " ").concat(y));
|
||||
}
|
||||
pathSegments.push('z');
|
||||
}
|
||||
}
|
||||
return pathSegments.join('');
|
||||
}
|
||||
function generatePath(modules, margin) {
|
||||
if (margin === void 0) { margin = 0; }
|
||||
var pathSegments = [];
|
||||
for (var y = 0; y < modules.length; y++) {
|
||||
var row = modules[y];
|
||||
var ops = [];
|
||||
modules.forEach(function (row, y) {
|
||||
var start = null;
|
||||
for (var x = 0; x < row.length; x++) {
|
||||
var cell = row[x];
|
||||
row.forEach(function (cell, x) {
|
||||
if (!cell && start !== null) {
|
||||
// M0 0h7v1H0z injects the space with the move and drops the comma,
|
||||
pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
|
||||
// saving a char per operation
|
||||
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
|
||||
start = null;
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
// end of row, clean up or skip
|
||||
if (x === row.length - 1) {
|
||||
if (!cell) {
|
||||
// We would have closed the op above already so this can only mean
|
||||
// 2+ light modules in a row.
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
if (start === null) {
|
||||
// Just a single dark module.
|
||||
pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
|
||||
ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
|
||||
}
|
||||
else {
|
||||
// Otherwise finish the current line.
|
||||
pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
|
||||
ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
|
||||
}
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
if (cell && start === null) {
|
||||
start = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pathSegments.join('');
|
||||
});
|
||||
});
|
||||
return ops.join('');
|
||||
}
|
||||
function getImageSettings(cells, size, margin, imageSettings) {
|
||||
var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y;
|
||||
var numCells = cells.length + margin * 2;
|
||||
var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO);
|
||||
var defaultSize = Math.floor(size * 0.1);
|
||||
var scale = numCells / size;
|
||||
var w = (width || defaultSize) * scale;
|
||||
var h = (height || defaultSize) * scale;
|
||||
var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale;
|
||||
var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale;
|
||||
var borderRadius = (imageSettings.borderRadius || 0) * scale;
|
||||
return { x: x, y: y, h: h, w: w, borderRadius: borderRadius };
|
||||
var excavation = null;
|
||||
if (imageSettings.excavate) {
|
||||
var floorX = Math.floor(x);
|
||||
var floorY = Math.floor(y);
|
||||
var ceilW = Math.ceil(w + x - floorX);
|
||||
var ceilH = Math.ceil(h + y - floorY);
|
||||
excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };
|
||||
}
|
||||
return { x: x, y: y, h: h, w: w, excavation: excavation };
|
||||
}
|
||||
function useQRCode(props) {
|
||||
var margin = vue.computed(function () { var _a; return ((_a = props.margin) !== null && _a !== void 0 ? _a : DEFAULT_MARGIN) >>> 0; });
|
||||
var cells = vue.computed(function () {
|
||||
var level = validErrorCorrectLevel(props.level) ? props.level : defaultErrorCorrectLevel;
|
||||
return QR.QrCode.encodeText(props.value, ErrorCorrectLevelMap[level]).getModules();
|
||||
});
|
||||
var numCells = vue.computed(function () { return cells.value.length + margin.value * 2; });
|
||||
var fgPath = vue.computed(function () {
|
||||
if (props.radius > 0) {
|
||||
return generateRoundedPath(cells.value, margin.value, props.radius);
|
||||
function excavateModules(modules, excavation) {
|
||||
return modules.slice().map(function (row, y) {
|
||||
if (y < excavation.y || y >= excavation.y + excavation.h) {
|
||||
return row;
|
||||
}
|
||||
return generatePath(cells.value, margin.value);
|
||||
return row.map(function (cell, x) {
|
||||
if (x < excavation.x || x >= excavation.x + excavation.w) {
|
||||
return cell;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
var imageProps = vue.computed(function () {
|
||||
if (!props.imageSettings.src)
|
||||
return null;
|
||||
var settings = getImageSettings(cells.value, props.size, margin.value, props.imageSettings);
|
||||
return {
|
||||
x: settings.x + margin.value,
|
||||
y: settings.y + margin.value,
|
||||
width: settings.w,
|
||||
height: settings.h,
|
||||
borderRadius: settings.borderRadius,
|
||||
};
|
||||
});
|
||||
var imageBorderProps = vue.computed(function () {
|
||||
if (!props.imageSettings.excavate || !imageProps.value)
|
||||
return null;
|
||||
var borderThickness = IMAGE_EXCAVATE_THICKNESS / (props.size / numCells.value);
|
||||
return {
|
||||
x: imageProps.value.x - borderThickness,
|
||||
y: imageProps.value.y - borderThickness,
|
||||
width: imageProps.value.width + borderThickness * 2,
|
||||
height: imageProps.value.height + borderThickness * 2,
|
||||
borderRadius: imageProps.value.borderRadius,
|
||||
};
|
||||
});
|
||||
return { margin: margin, numCells: numCells, cells: cells, fgPath: fgPath, imageProps: imageProps, imageBorderProps: imageBorderProps };
|
||||
}
|
||||
var QRCodeProps = {
|
||||
value: {
|
||||
@@ -1082,7 +1006,7 @@ var QRCodeProps = {
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: DEFAULT_QR_SIZE,
|
||||
default: 100,
|
||||
},
|
||||
level: {
|
||||
type: String,
|
||||
@@ -1100,7 +1024,7 @@ var QRCodeProps = {
|
||||
margin: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: DEFAULT_MARGIN,
|
||||
default: 0,
|
||||
},
|
||||
imageSettings: {
|
||||
type: Object,
|
||||
@@ -1128,12 +1052,6 @@ var QRCodeProps = {
|
||||
required: false,
|
||||
default: '#fff',
|
||||
},
|
||||
radius: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
validator: function (r) { return !isNaN(r) && r >= 0 && r <= 0.5; },
|
||||
},
|
||||
};
|
||||
var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: {
|
||||
type: String,
|
||||
@@ -1145,11 +1063,36 @@ var QrcodeSvg = vue.defineComponent({
|
||||
name: 'QRCodeSvg',
|
||||
props: QRCodeProps,
|
||||
setup: function (props) {
|
||||
var _a = useQRCode(props), numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
|
||||
var uid = getUid();
|
||||
var qrGradientId = "qrcode.vue-gradient-".concat(uid);
|
||||
var qrLogoClipPathId = "qrcode.vue-logo-clip-path-".concat(uid);
|
||||
var gradientVNode = vue.computed(function () {
|
||||
var numCells = vue.ref(0);
|
||||
var fgPath = vue.ref('');
|
||||
var imageProps;
|
||||
var generate = function () {
|
||||
var value = props.value, _level = props.level, _margin = props.margin;
|
||||
var margin = _margin >>> 0;
|
||||
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
|
||||
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
|
||||
numCells.value = cells.length + margin * 2;
|
||||
if (props.imageSettings.src) {
|
||||
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
|
||||
imageProps = {
|
||||
x: imageSettings.x + margin,
|
||||
y: imageSettings.y + margin,
|
||||
width: imageSettings.w,
|
||||
height: imageSettings.h,
|
||||
};
|
||||
if (imageSettings.excavation) {
|
||||
cells = excavateModules(cells, imageSettings.excavation);
|
||||
}
|
||||
}
|
||||
// Drawing strategy: instead of a rect per module, we're going to create a
|
||||
// single path for the dark modules and layer that on top of a light rect,
|
||||
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
|
||||
// way faster than DOM ops.
|
||||
// For level 1, 441 nodes -> 2
|
||||
// For level 40, 31329 -> 2
|
||||
fgPath.value = generatePath(cells, margin);
|
||||
};
|
||||
var renderGradient = function () {
|
||||
if (!props.gradient)
|
||||
return null;
|
||||
var gradientProps = props.gradientType === 'linear'
|
||||
@@ -1166,7 +1109,7 @@ var QrcodeSvg = vue.defineComponent({
|
||||
fx: '50%',
|
||||
fy: '50%',
|
||||
};
|
||||
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [
|
||||
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [
|
||||
vue.h('stop', {
|
||||
offset: '0%',
|
||||
style: { stopColor: props.gradientStartColor },
|
||||
@@ -1176,52 +1119,27 @@ var QrcodeSvg = vue.defineComponent({
|
||||
style: { stopColor: props.gradientEndColor },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
var clipPathVNode = vue.computed(function () {
|
||||
if (!imageProps.value)
|
||||
return null;
|
||||
var borderRadius = imageProps.value.borderRadius;
|
||||
if (borderRadius <= 0)
|
||||
return null;
|
||||
return vue.h('clipPath', { id: qrLogoClipPathId }, [
|
||||
vue.h('rect', {
|
||||
x: imageProps.value.x,
|
||||
y: imageProps.value.y,
|
||||
width: imageProps.value.width,
|
||||
height: imageProps.value.height,
|
||||
rx: borderRadius,
|
||||
ry: borderRadius,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
};
|
||||
generate();
|
||||
vue.onUpdated(generate);
|
||||
return function () { return vue.h('svg', {
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
'shape-rendering': "crispEdges",
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value),
|
||||
role: 'img',
|
||||
'aria-label': props.value,
|
||||
}, [
|
||||
vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]),
|
||||
vue.h('defs', {}, [renderGradient()]),
|
||||
vue.h('rect', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fill: props.background,
|
||||
}),
|
||||
vue.h('path', {
|
||||
fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground,
|
||||
fill: props.gradient ? 'url(#qr-gradient)' : props.foreground,
|
||||
d: fgPath.value,
|
||||
}),
|
||||
imageBorderProps.value && vue.h('rect', {
|
||||
x: imageBorderProps.value.x,
|
||||
y: imageBorderProps.value.y,
|
||||
width: imageBorderProps.value.width,
|
||||
height: imageBorderProps.value.height,
|
||||
fill: props.background,
|
||||
rx: imageBorderProps.value.borderRadius,
|
||||
ry: imageBorderProps.value.borderRadius,
|
||||
}),
|
||||
props.imageSettings.src && imageProps.value && vue.h('image', __assign(__assign({ href: props.imageSettings.src }, imageProps.value), (imageProps.value.borderRadius > 0 ? { 'clip-path': "url(#".concat(qrLogoClipPathId, ")") } : {}))),
|
||||
props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)),
|
||||
]); };
|
||||
},
|
||||
});
|
||||
@@ -1229,89 +1147,81 @@ var QrcodeCanvas = vue.defineComponent({
|
||||
name: 'QRCodeCanvas',
|
||||
props: QRCodeProps,
|
||||
setup: function (props, ctx) {
|
||||
var _a = useQRCode(props), margin = _a.margin, cells = _a.cells, numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
|
||||
var canvasEl = vue.ref(null);
|
||||
var imageEl = vue.ref(null);
|
||||
var drawRoundedRect = function (ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
}
|
||||
else {
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
};
|
||||
var imageRef = vue.ref(null);
|
||||
var generate = function () {
|
||||
var size = props.size, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
|
||||
var value = props.value, _level = props.level, size = props.size, _margin = props.margin, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
|
||||
var margin = _margin >>> 0;
|
||||
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
|
||||
var canvas = canvasEl.value;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
var canvasCtx = canvas.getContext('2d');
|
||||
if (!canvasCtx) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
var image = imageEl.value;
|
||||
var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||||
var scale = (size / numCells.value) * devicePixelRatio;
|
||||
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
|
||||
var numCells = cells.length + margin * 2;
|
||||
var image = imageRef.value;
|
||||
var imageProps = { x: 0, y: 0, width: 0, height: 0 };
|
||||
var showImage = props.imageSettings.src && image != null && image.naturalWidth !== 0 && image.naturalHeight !== 0;
|
||||
if (showImage) {
|
||||
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
|
||||
imageProps = {
|
||||
x: imageSettings.x + margin,
|
||||
y: imageSettings.y + margin,
|
||||
width: imageSettings.w,
|
||||
height: imageSettings.h,
|
||||
};
|
||||
if (imageSettings.excavation) {
|
||||
cells = excavateModules(cells, imageSettings.excavation);
|
||||
}
|
||||
}
|
||||
var devicePixelRatio = window.devicePixelRatio || 1;
|
||||
var scale = (size / numCells) * devicePixelRatio;
|
||||
canvas.height = canvas.width = size * devicePixelRatio;
|
||||
canvasCtx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
canvasCtx.fillStyle = background;
|
||||
canvasCtx.fillRect(0, 0, numCells.value, numCells.value);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, numCells, numCells);
|
||||
if (gradient) {
|
||||
var grad = void 0;
|
||||
if (gradientType === 'linear') {
|
||||
grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value);
|
||||
grad = ctx.createLinearGradient(0, 0, numCells, numCells);
|
||||
}
|
||||
else {
|
||||
grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2);
|
||||
grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2);
|
||||
}
|
||||
grad.addColorStop(0, gradientStartColor);
|
||||
grad.addColorStop(1, gradientEndColor);
|
||||
canvasCtx.fillStyle = grad;
|
||||
ctx.fillStyle = grad;
|
||||
}
|
||||
else {
|
||||
canvasCtx.fillStyle = foreground;
|
||||
ctx.fillStyle = foreground;
|
||||
}
|
||||
if (SUPPORTS_PATH2D) {
|
||||
canvasCtx.fill(new Path2D(fgPath.value));
|
||||
ctx.fill(new Path2D(generatePath(cells, margin)));
|
||||
}
|
||||
else {
|
||||
cells.value.forEach(function (row, rdx) {
|
||||
cells.forEach(function (row, rdx) {
|
||||
row.forEach(function (cell, cdx) {
|
||||
if (cell) {
|
||||
canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1);
|
||||
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
var showImage = props.imageSettings.src && image && image.naturalWidth !== 0 && image.naturalHeight !== 0;
|
||||
if (showImage && imageProps.value) {
|
||||
if (imageBorderProps.value) {
|
||||
var imageBorder = imageBorderProps.value;
|
||||
canvasCtx.fillStyle = props.background;
|
||||
drawRoundedRect(canvasCtx, imageBorder.x, imageBorder.y, imageBorder.width, imageBorder.height, imageBorder.borderRadius);
|
||||
canvasCtx.fill();
|
||||
}
|
||||
var borderRadius = imageProps.value.borderRadius;
|
||||
if (borderRadius > 0) {
|
||||
canvasCtx.save();
|
||||
drawRoundedRect(canvasCtx, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height, borderRadius);
|
||||
canvasCtx.clip();
|
||||
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
|
||||
canvasCtx.restore();
|
||||
}
|
||||
else {
|
||||
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
|
||||
}
|
||||
if (showImage) {
|
||||
ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height);
|
||||
}
|
||||
};
|
||||
vue.onMounted(generate);
|
||||
vue.watchEffect(generate);
|
||||
vue.onUpdated(generate);
|
||||
var style = ctx.attrs.style;
|
||||
return function () { return vue.h(vue.Fragment, [
|
||||
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, role: 'img', 'aria-label': props.value, style: __assign(__assign({}, ctx.attrs.style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
|
||||
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
|
||||
props.imageSettings.src && vue.h('img', {
|
||||
ref: imageEl,
|
||||
ref: imageRef,
|
||||
src: props.imageSettings.src,
|
||||
style: { display: 'none' },
|
||||
onLoad: generate,
|
||||
@@ -1321,23 +1231,23 @@ var QrcodeCanvas = vue.defineComponent({
|
||||
});
|
||||
var QrcodeVue = vue.defineComponent({
|
||||
name: 'Qrcode',
|
||||
props: QRCodeVueProps,
|
||||
setup: function (props) {
|
||||
return function () { return vue.h(props.renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
|
||||
value: props.value,
|
||||
size: props.size,
|
||||
margin: props.margin,
|
||||
level: props.level,
|
||||
background: props.background,
|
||||
foreground: props.foreground,
|
||||
imageSettings: props.imageSettings,
|
||||
gradient: props.gradient,
|
||||
gradientType: props.gradientType,
|
||||
gradientStartColor: props.gradientStartColor,
|
||||
gradientEndColor: props.gradientEndColor,
|
||||
radius: props.radius,
|
||||
}); };
|
||||
render: function () {
|
||||
var _a = this.$props, renderAs = _a.renderAs, value = _a.value, size = _a.size, margin = _a.margin, level = _a.level, background = _a.background, foreground = _a.foreground, imageSettings = _a.imageSettings, gradient = _a.gradient, gradientType = _a.gradientType, gradientStartColor = _a.gradientStartColor, gradientEndColor = _a.gradientEndColor;
|
||||
return vue.h(renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
|
||||
value: value,
|
||||
size: size,
|
||||
margin: margin,
|
||||
level: level,
|
||||
background: background,
|
||||
foreground: foreground,
|
||||
imageSettings: imageSettings,
|
||||
gradient: gradient,
|
||||
gradientType: gradientType,
|
||||
gradientStartColor: gradientStartColor,
|
||||
gradientEndColor: gradientEndColor,
|
||||
});
|
||||
},
|
||||
props: QRCodeVueProps,
|
||||
});
|
||||
|
||||
exports.QrcodeCanvas = QrcodeCanvas;
|
||||
|
||||
@@ -43,7 +43,8 @@ summary {
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,7 +200,8 @@ input[type=search]::-webkit-search-decoration {
|
||||
.material-symbols-outlined,
|
||||
.material-symbols-rounded,
|
||||
.material-symbols-sharp {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: inherit;
|
||||
font-size: inherit;
|
||||
display: inline-flex;
|
||||
@@ -996,7 +998,8 @@ input[type=search]::-webkit-search-decoration {
|
||||
height: 1px;
|
||||
}
|
||||
.q-checkbox__bg, .q-checkbox__icon-container {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.q-checkbox__bg {
|
||||
top: 25%;
|
||||
@@ -2209,7 +2212,8 @@ body.q-ios-padding .q-dialog__inner > div {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
outline: 0 !important;
|
||||
user-select: auto;
|
||||
-webkit-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
|
||||
-webkit-animation-name: q-autofill;
|
||||
@@ -3035,7 +3039,8 @@ body.body--dark .q-knob--editable:focus:before {
|
||||
z-index: 2001;
|
||||
height: 100%;
|
||||
width: 15px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.q-layout, .q-header, .q-footer, .q-page {
|
||||
@@ -3292,7 +3297,8 @@ body.platform-ios .q-layout--containerized {
|
||||
height: 1px;
|
||||
}
|
||||
.q-radio__bg, .q-radio__icon-container {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.q-radio__bg {
|
||||
top: 25%;
|
||||
@@ -3776,7 +3782,8 @@ body.platform-ios:not(.native-mobile) .q-dialog__inner--top .q-select__dialog--f
|
||||
.q-slide-item__content {
|
||||
background: inherit;
|
||||
transition: transform 0.2s ease-in;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -4149,7 +4156,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
|
||||
}
|
||||
.q-splitter__separator {
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -4238,7 +4246,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
|
||||
color: #000;
|
||||
}
|
||||
.q-stepper__tab--navigation {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.q-stepper__tab--active, .q-stepper__tab--done {
|
||||
@@ -4470,7 +4479,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
|
||||
.q-table th {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.q-table th.sortable {
|
||||
cursor: pointer;
|
||||
@@ -5474,7 +5484,8 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.q-toggle__thumb:after {
|
||||
@@ -10326,7 +10337,6 @@ body.body--dark .inset-shadow-down {
|
||||
.glossy {
|
||||
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.12) 51%, rgba(0, 0, 0, 0.04)) !important;
|
||||
}
|
||||
|
||||
.q-placeholder::placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
@@ -10358,7 +10368,8 @@ body.body--dark .inset-shadow-down {
|
||||
text-decoration: none;
|
||||
}
|
||||
.q-link--focusable:focus-visible {
|
||||
text-decoration: underline dashed currentColor 1px;
|
||||
-webkit-text-decoration: underline dashed currentColor 1px;
|
||||
text-decoration: underline dashed currentColor 1px;
|
||||
}
|
||||
|
||||
body.electron .q-electron-drag {
|
||||
@@ -10375,7 +10386,8 @@ img.responsive {
|
||||
}
|
||||
|
||||
.non-selectable {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.scroll,
|
||||
@@ -11033,7 +11045,8 @@ body.q-ios-padding .fullscreen {
|
||||
}
|
||||
|
||||
.q-touch {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
user-drag: none;
|
||||
-khtml-user-drag: none;
|
||||
-webkit-user-drag: none;
|
||||
|
||||
@@ -6,10 +6,7 @@ from collections.abc import Callable, Coroutine
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services.payments import (
|
||||
get_standalone_payment,
|
||||
update_invoice_from_paid_invoices_stream,
|
||||
)
|
||||
from lnbits.core.services.payments import update_invoice_callback
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
@@ -115,7 +112,7 @@ async def internal_invoice_listener() -> None:
|
||||
while settings.lnbits_running:
|
||||
checking_id = await internal_invoice_queue.get()
|
||||
logger.info(f"got an internal payment notification {checking_id}")
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
payment = await update_invoice_callback(checking_id)
|
||||
if payment:
|
||||
logger.success(f"internal invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
@@ -131,7 +128,7 @@ async def invoice_listener() -> None:
|
||||
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)
|
||||
payment = await update_invoice_callback(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% include('components/admin/funding_seed_backup.vue') %} {%
|
||||
include('components/admin/funding.vue') %} {%
|
||||
{% include('components/admin/funding.vue') %} {%
|
||||
include('components/admin/funding_sources.vue') %} {%
|
||||
include('components/admin/fiat_providers.vue') %} {%
|
||||
include('components/admin/exchange_providers.vue') %} {%
|
||||
@@ -775,13 +774,7 @@ include('components/lnbits-error.vue') %}
|
||||
v-model="password"
|
||||
name="password"
|
||||
:label="$t('password') + ' *'"
|
||||
:type="showPwd ? 'text' : 'password'"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPwd ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="showPwd = !showPwd"
|
||||
/> </template
|
||||
type="password"
|
||||
></q-input>
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
@@ -810,28 +803,16 @@ include('components/lnbits-error.vue') %}
|
||||
filled
|
||||
v-model="password"
|
||||
:label="$t('password') + ' *'"
|
||||
:type="showPwd ? 'text' : 'password'"
|
||||
type="password"
|
||||
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPwd ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="showPwd = !showPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="passwordRepeat"
|
||||
:label="$t('password_repeat') + ' *'"
|
||||
:type="showPwdRepeat ? 'text' : 'password'"
|
||||
type="password"
|
||||
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="showPwdRepeat = !showPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<div
|
||||
v-if="confirmationMethodsCount > 1"
|
||||
@@ -944,28 +925,16 @@ include('components/lnbits-error.vue') %}
|
||||
filled
|
||||
v-model="password"
|
||||
:label="$t('password') + ' *'"
|
||||
:type="showPwd ? 'text' : 'password'"
|
||||
type="password"
|
||||
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPwd ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="showPwd = !showPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="passwordRepeat"
|
||||
:label="$t('password_repeat') + ' *'"
|
||||
:type="showPwdRepeat ? 'text' : 'password'"
|
||||
type="password"
|
||||
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
|
||||
class="cursor-pointer"
|
||||
@click="showPwdRepeat = !showPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
|
||||
@@ -1,46 +1,7 @@
|
||||
<template id="lnbits-admin-exchange-providers">
|
||||
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
|
||||
<p class="q-mb-md text-caption text-grey">
|
||||
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
|
||||
LNbits team. Aggregates prices from multiple exchanges and returns a median,
|
||||
no API keys required.
|
||||
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
|
||||
>price.lnbits.com</a
|
||||
>
|
||||
—
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits-price-aggregator"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>GitHub</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="row q-mb-md items-start">
|
||||
<div class="col-auto q-mr-md q-mt-sm">
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_price_aggregator_enabled"
|
||||
@update:model-value="formData.touch = null"
|
||||
label="Use Price Aggregator"
|
||||
>
|
||||
</q-toggle>
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_price_aggregator_url"
|
||||
type="text"
|
||||
label="Price Aggregator URL"
|
||||
hint="Fetch BTC price from this aggregator instead of individual providers below."
|
||||
:disable="!formData.lnbits_price_aggregator_enabled"
|
||||
@update:model-value="formData.touch = null"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
@@ -92,11 +53,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@@ -104,7 +60,6 @@
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
:disable="formData.lnbits_price_aggregator_enabled"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
@@ -115,20 +70,12 @@
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
:disable="formData.lnbits_price_aggregator_enabled"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-auto"
|
||||
:style="
|
||||
formData.lnbits_price_aggregator_enabled
|
||||
? 'opacity:0.4;pointer-events:none'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="overflow-auto">
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
|
||||
@@ -587,544 +587,12 @@
|
||||
<q-item-section> Square </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">
|
||||
<q-toggle
|
||||
size="md"
|
||||
:label="$t('enabled')"
|
||||
v-model="formData.square_enabled"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</div>
|
||||
<div class="row items-center">Disabled</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card class="q-pb-xl">
|
||||
<q-expansion-item :label="$t('api')" default-opened>
|
||||
<q-card-section class="q-pa-md">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.square_api_endpoint"
|
||||
:label="$t('endpoint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.square_access_token"
|
||||
:label="$t('access_token')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.square_location_id"
|
||||
:label="$t('location_id')"
|
||||
:hint="$t('square_location_id_hint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.square_api_version"
|
||||
:label="$t('api_version')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.square_payment_success_url"
|
||||
:label="$t('callback_success_url')"
|
||||
:hint="$t('callback_success_url_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="float-right"
|
||||
:label="$t('check_connection')"
|
||||
@click="checkFiatProvider('square')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('webhook')" default-opened>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_square_description')"></span>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row items-center q-gutter-sm q-mt-md">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.square_payment_webhook_url"
|
||||
:label="$t('webhook_url')"
|
||||
:hint="$t('square_webhook_url_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="content_copy"
|
||||
@click="
|
||||
copyWebhookUrl(formData.square_payment_webhook_url)
|
||||
"
|
||||
:aria-label="$t('copy_webhook_url')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('copy_webhook_url')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.square_webhook_signature_key"
|
||||
:label="$t('signing_secret')"
|
||||
:hint="$t('square_webhook_signature_key_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_events_list')"></span>
|
||||
<ul>
|
||||
<li><code>payment.updated</code></li>
|
||||
<li><code>invoice.payment_made</code></li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('service_fee')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.square_limits.service_fee_percent"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('service_fee_label')"
|
||||
:hint="$t('service_fee_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.square_limits.service_max_fee_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('service_fee_max')"
|
||||
:hint="$t('service_fee_max_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="text"
|
||||
v-model="formData.square_limits.service_fee_wallet_id"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('fee_wallet_label')"
|
||||
:hint="$t('fee_wallet_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('amount_limits')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.square_limits.service_min_amount_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('min_incoming_payment_amount')"
|
||||
:hint="$t('min_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.square_limits.service_max_amount_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('max_incoming_payment_amount')"
|
||||
:hint="$t('max_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
v-model="formData.square_limits.service_faucet_wallet_id"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('faucest_wallet_id')"
|
||||
:hint="$t('faucest_wallet_id_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul>
|
||||
<li>
|
||||
<span
|
||||
v-text="
|
||||
$t('faucest_wallet_desc_1', {
|
||||
provider: 'square'
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="
|
||||
$t('faucest_wallet_desc_2', {
|
||||
provider: 'square'
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_3')"></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="
|
||||
$t('faucest_wallet_desc_4', {
|
||||
provider: 'square'
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_5')"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('allowed_users')">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddSquareUser"
|
||||
@keydown.enter="addSquareAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="
|
||||
$t('allowed_users_hint_feature', {
|
||||
feature: 'Square'
|
||||
})
|
||||
"
|
||||
>
|
||||
<q-btn
|
||||
@click="addSquareAllowedUser"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.square_limits.allowed_users"
|
||||
@update:model-value="formData.touch = null"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeSquareAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Revolut </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">
|
||||
<q-toggle
|
||||
size="md"
|
||||
:label="$t('enabled')"
|
||||
v-model="formData.revolut_enabled"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card class="q-pb-xl">
|
||||
<q-expansion-item :label="$t('api')" default-opened>
|
||||
<q-card-section class="q-pa-md">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.revolut_api_endpoint"
|
||||
:label="$t('endpoint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.revolut_api_secret_key"
|
||||
label="API secret key"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.revolut_api_version"
|
||||
:label="$t('api_version')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.revolut_payment_success_url"
|
||||
:label="$t('callback_success_url')"
|
||||
:hint="$t('callback_success_url_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="float-right"
|
||||
:label="$t('check_connection')"
|
||||
@click="checkFiatProvider('revolut')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('webhook')" default-opened>
|
||||
<q-card-section>
|
||||
Configure a Revolut Merchant webhook that points to your LNbits
|
||||
server. LNbits will create it through the Revolut API and
|
||||
subscribe to <code>ORDER_AUTHORISED</code>,
|
||||
<code>ORDER_COMPLETED</code>, and
|
||||
<code>SUBSCRIPTION_INITIATED</code>.
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row items-center q-gutter-sm q-mt-md">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
disable
|
||||
:model-value="revolutWebhookUrl"
|
||||
:label="$t('webhook_url')"
|
||||
readonly
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="content_copy"
|
||||
@click="copyWebhookUrl(revolutWebhookUrl)"
|
||||
:aria-label="$t('copy_webhook_url')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('copy_webhook_url')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm q-mt-md">
|
||||
<q-btn
|
||||
type="button"
|
||||
color="primary"
|
||||
icon="add_link"
|
||||
label="Create webhook"
|
||||
:loading="creatingRevolutWebhook"
|
||||
@click="createRevolutWebhook"
|
||||
></q-btn>
|
||||
<q-chip
|
||||
v-if="formData.revolut_webhook_signing_secret"
|
||||
dense
|
||||
color="positive"
|
||||
text-color="white"
|
||||
icon="verified"
|
||||
>
|
||||
Signing secret saved
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_events_list')"></span>
|
||||
<ul>
|
||||
<li>
|
||||
<code>ORDER_AUTHORISED</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>ORDER_COMPLETED</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>SUBSCRIPTION_INITIATED</code>
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('service_fee')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.revolut_limits.service_fee_percent"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('service_fee_label')"
|
||||
:hint="$t('service_fee_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.revolut_limits.service_max_fee_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('service_fee_max')"
|
||||
:hint="$t('service_fee_max_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="text"
|
||||
v-model="formData.revolut_limits.service_fee_wallet_id"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('fee_wallet_label')"
|
||||
:hint="$t('fee_wallet_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('amount_limits')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.revolut_limits.service_min_amount_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('min_incoming_payment_amount')"
|
||||
:hint="$t('min_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.revolut_limits.service_max_amount_sats"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('max_incoming_payment_amount')"
|
||||
:hint="$t('max_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
v-model="formData.revolut_limits.service_faucet_wallet_id"
|
||||
@update:model-value="formData.touch = null"
|
||||
:label="$t('faucest_wallet_id')"
|
||||
:hint="$t('faucest_wallet_id_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('allowed_users')">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddRevolutUser"
|
||||
@keydown.enter="addRevolutAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="
|
||||
$t('allowed_users_hint_feature', {
|
||||
feature: 'Revolut'
|
||||
})
|
||||
"
|
||||
>
|
||||
<q-btn
|
||||
@click="addRevolutAllowedUser"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.revolut_limits.allowed_users"
|
||||
@update:model-value="formData.touch = null"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeRevolutAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-card>
|
||||
<q-card-section> Coming Soon </q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
@@ -1172,22 +640,9 @@
|
||||
>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-bold" style="min-width: 140px">Square</div>
|
||||
<q-chip dense color="positive" text-color="white" icon="check"
|
||||
>Checkout</q-chip
|
||||
>
|
||||
<q-chip dense color="warning" text-color="black" icon="schedule"
|
||||
>Subscriptions coming soon</q-chip
|
||||
>
|
||||
<q-chip dense color="negative" text-color="white" icon="close"
|
||||
>Tap-to-pay</q-chip
|
||||
>
|
||||
<q-chip dense color="grey-9" text-color="white" icon="public"
|
||||
>Regions: Square-supported countries</q-chip
|
||||
>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-bold" style="min-width: 140px">Revolut</div>
|
||||
<div class="text-bold" style="min-width: 140px">
|
||||
Square (coming soon)
|
||||
</div>
|
||||
<q-chip dense color="positive" text-color="white" icon="check"
|
||||
>Checkout</q-chip
|
||||
>
|
||||
@@ -1198,7 +653,7 @@
|
||||
>Tap-to-pay</q-chip
|
||||
>
|
||||
<q-chip dense color="grey-9" text-color="white" icon="public"
|
||||
>Regions: Revolut-supported countries</q-chip
|
||||
>Regions: Global</q-chip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,35 +75,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mt-lg">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('routing_fee_reserve_calculations')"></span>
|
||||
@@ -210,6 +182,34 @@
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="q-mt-lg q-mb-sm">
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
@@ -301,11 +301,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<lnbits-admin-funding-seed-backup
|
||||
:active="active"
|
||||
:is-super-user="isSuperUser"
|
||||
:form-data="formData"
|
||||
:settings="settings"
|
||||
></lnbits-admin-funding-seed-backup>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<template id="lnbits-admin-funding-seed-backup">
|
||||
<q-dialog v-model="dialog.show">
|
||||
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
|
||||
<q-card-section class="q-pb-md">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<q-chip
|
||||
square
|
||||
class="full-width"
|
||||
icon="looks_one"
|
||||
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
|
||||
text-color="white"
|
||||
label="Backup"
|
||||
></q-chip>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-chip
|
||||
square
|
||||
class="full-width"
|
||||
icon="looks_two"
|
||||
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
|
||||
text-color="white"
|
||||
label="Verify"
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section v-if="dialog.step === 1">
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div>
|
||||
<div
|
||||
class="text-subtitle1"
|
||||
v-text="`${seedWords.length}-word recovery phrase`"
|
||||
></div>
|
||||
<div
|
||||
class="text-caption text-grey-5"
|
||||
v-text="'Write these words down in order.'"
|
||||
></div>
|
||||
</div>
|
||||
<q-btn
|
||||
outline
|
||||
no-caps
|
||||
color="primary"
|
||||
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
|
||||
:label="dialog.visible ? 'Hide words' : 'Show words'"
|
||||
@click="dialog.visible = !dialog.visible"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div
|
||||
class="col-4 col-md-3"
|
||||
v-for="word in seedWords"
|
||||
:key="word.index"
|
||||
>
|
||||
<div
|
||||
class="row items-center no-wrap rounded-borders"
|
||||
style="
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="text-caption text-grey-5 text-center"
|
||||
style="
|
||||
width: 42px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
"
|
||||
v-text="word.index + 1"
|
||||
></div>
|
||||
<div
|
||||
class="text-body2 text-weight-medium q-px-sm"
|
||||
style="min-width: 0; overflow-wrap: anywhere"
|
||||
v-text="dialog.visible ? word.word : '••••••'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-mt-lg">
|
||||
<q-btn
|
||||
color="primary"
|
||||
no-caps
|
||||
label="I have written it down"
|
||||
@click="prepareChallenge"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="dialog.step === 2">
|
||||
<div class="q-mb-md">
|
||||
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
|
||||
<div
|
||||
class="text-caption text-grey-5"
|
||||
v-text="
|
||||
'Enter the requested words from your written recovery phrase.'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-12 col-sm-6"
|
||||
v-for="word in dialog.challenge"
|
||||
:key="word.index"
|
||||
>
|
||||
<q-input
|
||||
v-model.trim="dialog.answers[word.index]"
|
||||
filled
|
||||
:label="`Word ${word.index + 1}`"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-negative q-mt-sm"
|
||||
v-if="dialog.error"
|
||||
v-text="dialog.error"
|
||||
></div>
|
||||
<div class="row justify-between q-mt-lg">
|
||||
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="check"
|
||||
no-caps
|
||||
label="Confirm backup"
|
||||
@click="submitChallenge"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
@@ -52,7 +52,6 @@
|
||||
:label="prop.label"
|
||||
:hint="prop.hint"
|
||||
:value="prop.value"
|
||||
:readonly="prop.readonly || false"
|
||||
>
|
||||
<q-btn
|
||||
v-if="prop.copy"
|
||||
@@ -74,15 +73,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
</div>
|
||||
<p
|
||||
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
|
||||
class="col-12 q-my-md"
|
||||
>
|
||||
<span>
|
||||
<q-icon name="warning" color="orange" size="xs"></q-icon>
|
||||
<span v-text="$t('phoenixd_warning')"></span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<q-expansion-item
|
||||
v-if="
|
||||
|
||||
@@ -251,27 +251,10 @@
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_bgimage"
|
||||
label="Background Image"
|
||||
@update:model-value="applyGlobalBgimage"
|
||||
hint="This must be a trusted source. It can change the content and it can log your IP address."
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="upload"
|
||||
@click="$refs.adminBackgroundImageInput.click()"
|
||||
>
|
||||
<q-tooltip>Upload background image</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<input
|
||||
type="file"
|
||||
ref="adminBackgroundImageInput"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onBackgroundImageInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
@@ -320,15 +303,6 @@
|
||||
>
|
||||
</q-toggle>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-2">
|
||||
<q-toggle
|
||||
type="bool"
|
||||
v-model="formData.lnbits_default_burger_menu_background"
|
||||
color="primary"
|
||||
:label="$t('burger_menu_background')"
|
||||
>
|
||||
</q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||