Compare commits

...
Author SHA1 Message Date
dni df8e69218e feat: cache was blocking request instead refreshing in background
feat: track tasks in cache
2026-06-08 16:12:16 +02:00
Tiago VasconcelosandGitHub 1367480ec6 open href on new tab on QR click (#3989) 2026-06-08 14:00:18 +03:00
ArcandGitHub e2d83b516a feat: add boltz-client (#3997) 2026-06-05 10:38:46 +02:00
dni ⚡andGitHub 83699289fc chore: update to v1.5.5-rc2 (#3998) 2026-06-04 13:46:29 +02:00
Vlad StanandGitHub f04e88d8bf fix: CSV export limit (#3996) 2026-06-03 14:41:56 +03:00
Vlad StanandGitHub 564edfc447 fix: do not hit sso provider on user missmatch (#3995) 2026-06-03 14:29:54 +03:00
dni ⚡andGitHub b98515df14 chore: update pyright to latest and fix new issues (#3978) 2026-06-03 11:46:53 +02:00
Tiago VasconcelosandGitHub 1e0fc84586 Fix: Allow the LNURL spec fallback scheme for LNURLw (#3961) 2026-06-03 10:25:11 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1d94834ae chore(deps): bump idna from 3.14 to 3.15 (#3981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 10:21:47 +02:00
5de4239f3c fix: revolut subscriptions (#3994)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-03 11:21:44 +03:00
c404666d7f fix: theming was slowing frontend (#3992)
Co-authored-by: alan <alan@lnbits.com>
2026-06-02 10:19:27 +02:00
dni ⚡andGitHub 190a466c0a fix: update_payment should return the updated payment (#3983) 2026-05-25 14:29:39 +03:00
Riccardo BalboandGitHub d01e3523d8 Merge commit from fork 2026-05-25 14:28:11 +03:00
krviandGitHub 88672501d8 fix: make payments tags chart display dependant on showPaymentTags (#3943) 2026-05-25 10:24:40 +03:00
dni ⚡andGitHub ce57d08163 chore: update to v1.5.5-rc1 (#3990) 2026-05-24 21:41:05 +02:00
52304e0730 feat: allow extra appending (#3974)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:29:13 +01:00
6664eebf5a feat: add visibility toggle to inputs (#3940)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:14:19 +01:00
2a2af81827 feat: hide burger bg toggle (#3969)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-05-22 14:04:46 +01:00
ArcandGitHub 9b47f6323f fIx: pyinstrument import needed for nix (#3985) 2026-05-22 13:56:06 +03:00
Vlad StanandGitHub a61807a257 feat: square integration (#3962) 2026-05-22 12:37:51 +03:00
Vlad StanandGitHub 30e0522419 fix: stricter asset upload (#3982) 2026-05-21 12:44:23 +02:00
dni ⚡andGitHub 8f033a6047 fix: OIDC SSO login issues. closes #3970 (#3979) 2026-05-20 09:27:02 +02:00
dni ⚡andGitHub a2c817a56b chore: update python packages (#3977) 2026-05-19 10:58:14 +02:00
555350085e feat: add min-release-age to .npmrc + update packages (#3975)
Co-authored-by: alan <alan@lnbits.com>
2026-05-19 09:41:32 +02:00
dni ⚡andGitHub fc5061a67f chore: remove unused library Chart (#3976) 2026-05-19 09:36:14 +02:00
Tiago VasconcelosandGitHub c9c68bd8d7 Fix: Use default reaction on bootstrap (#3965) 2026-05-14 04:56:41 +02:00
Tiago VasconcelosandGitHub 810a13722c fix: tighten agents file (#3966) 2026-05-13 15:23:09 +03:00
Tiago VasconcelosandGitHub 36d696b222 Fix: wrong use of in operator (#3960) 2026-05-08 11:38:12 +03:00
dni ⚡andGitHub 8b426efa3e test: add pyinstrument profiler (#3955) 2026-05-07 17:15:53 +02:00
9edc4786e1 Fix: “Show all” table pagination (#3946)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-05-07 12:02:19 +03:00
Tiago VasconcelosandGitHub 93dc10fe94 Feat: add AI guardrails to work on LNbits (#3942) 2026-05-07 11:22:38 +03:00
ArcandGitHub 6c8448d7a8 fix: remove opensats (#3951) 2026-05-01 15:07:49 +01:00
dni ⚡andGitHub 99e4f33142 chore: update to version v1.5.4 (#3939) 2026-04-23 11:12:06 +02:00
ArcandGitHub f4f43ad361 fix: Switching images to local (#3938) 2026-04-21 11:41:45 +01:00
ArcandGitHub f14ea6c577 fix: add auth_https_only to env.example (#3937) 2026-04-17 14:50:26 +03:00
dni ⚡andGitHub 07428ecf94 chore: update to version v1.5.4-rc1 (#3935) 2026-04-16 15:43:33 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
867e3d06f6 chore(deps): bump python-multipart from 0.0.22 to 0.0.26 (#3933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 14:05:45 +02:00
4b4b6d0bcd feat: adds an upload for assets on backgroundImage in account and site customisation (#3929)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 14:05:24 +02:00
07b1521dad feat: Add phoenixd mnemonic display (#3931)
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 13:42:58 +02:00
7a2ddd9826 chore: update axios upgrade to 1.15.0 (#3936)
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 13:25:37 +02:00
ArcandGitHub 0eb4b477b7 feat: ui, adds full/thumb buttons to assets (#3928) 2026-04-16 13:17:11 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
06a0ba58ce chore(deps): bump pytest from 9.0.2 to 9.0.3 (#3930)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 13:15:33 +02:00
dni ⚡andGitHub 5399b36027 chore: revert changing default auth (#3934) 2026-04-16 11:30:40 +02:00
ArcandGitHub 385fb4f9bc fix: first_install for local (#3927) 2026-04-14 14:50:20 +01:00
ArcandGitHub 8db76b8864 fix: Appimage (#3926) 2026-04-12 23:01:25 +01:00
ArcandGitHub 9e3ab0ef26 feat: max users + extensions env (#3919) 2026-04-12 22:38:36 +01:00
ArcandGitHub 04c9b67997 fix: funding source ui (#3920) 2026-04-12 22:34:27 +01:00
Vlad StanandGitHub 116f982aab fix: webhook can fail for multiple reasons (#3921) 2026-04-08 18:17:39 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
63cc89e2b6 chore(deps): bump pygments from 2.19.2 to 2.20.0 (#3912)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 09:49:38 +03:00
Vlad StanandGitHub 183e6e5661 [test] Codex tests (#3911) 2026-03-31 09:48:43 +03:00
Vlad StanandGitHub 6b3fd80e46 [tests] Wallets test editor (#3910) 2026-03-30 13:12:26 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5999a773be chore(deps): bump cryptography from 46.0.5 to 46.0.6 (#3909)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 10:16:01 +03:00
dni ⚡andGitHub 7e0fadad3b refactor: move payment code from tasks.py to service/payments.py (#3800) 2026-03-26 11:28:23 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b2d6243697 chore(deps): bump requests from 2.32.5 to 2.33.0 (#3903)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 11:07:50 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6b5a77fb3b chore(deps): bump picomatch from 2.3.1 to 2.3.2 (#3901)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 09:58:34 +02:00
dni ⚡andGitHub cc8fb68b02 ci: only run ci on pull request (#3902) 2026-03-26 09:37:36 +02:00
Vlad StanandGitHub 658da6b28e feat: optimize QR code value for bach32 (#3900) 2026-03-25 16:22:03 +02:00
Vlad StanandGitHub 7d734ecb74 fix: use --offline for uv (#3896) 2026-03-25 13:10:47 +02:00
dni ⚡andGitHub 9177dd195b chore: update to version 1.5.3 (#3899) 2026-03-25 12:08:39 +01:00
15faab4f38 [feat] reset the first install token (#3894) (#3898)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-03-25 11:57:37 +01:00
dni ⚡andGitHub 2cce687865 bugfix: template regression from last commit (#3895) 2026-03-24 17:19:28 +01:00
dni ⚡andGitHub 313bd3f647 chore: move core/templates into templates/, remove unused and deprecate macro (#3804) 2026-03-24 08:41:06 +01:00
13a93836d9 feat: do automatic bundling make bundle on the CI (#3889)
Co-authored-by: alan <alan@lnbits.com>
2026-03-24 08:34:39 +01:00
ArcandGitHub 4f76d0483e fix: restore classic theme (#3893) 2026-03-24 09:18:58 +02:00
dni ⚡andGitHub 719d86aa9c chore: update to version v1.5.2 (#3891) 2026-03-23 17:39:08 +01:00
159 changed files with 25161 additions and 27102 deletions
+13 -1
View File
@@ -7,14 +7,20 @@
# They are NOT managed by the Admin UI and are not stored in the database. # They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token === # === First Install Token ===
# if set the user is required to enter this token on the /first_install page # If set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken" # FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development === # === Logging and Development ===
DEBUG=False DEBUG=False
DEBUG_DATABASE=False DEBUG_DATABASE=False
BUNDLE_ASSETS=True BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/ # logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true ENABLE_LOG_TO_FILE=true
@@ -24,6 +30,10 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings === # === Admin Settings ===
@@ -109,6 +119,8 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack." LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
###################################### ######################################
+3
View File
@@ -69,7 +69,10 @@ jobs:
--onefile \ --onefile \
--name lnbits \ --name lnbits \
--hidden-import=embit \ --hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \ --collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \ --collect-all lnbits \
--collect-all sqlalchemy \ --collect-all sqlalchemy \
--collect-all breez_sdk \ --collect-all breez_sdk \
+29
View File
@@ -0,0 +1,29 @@
name: bundle
on:
workflow_call:
jobs:
bundle:
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: "3.10"
node-version: "24.x"
npm: true
- run: make bundle
- name: Commit and push bundle changes
run: |
git config user.name "alan"
git config user.email "alan@lnbits.com"
git add lnbits/static
if git diff --cached --quiet; then
exit 0
fi
git commit -m "chore: make bundle [skip ci]"
git push
+4 -5
View File
@@ -1,14 +1,9 @@
name: LNbits CI name: LNbits CI
on: on:
push:
branches:
- main
- dev
pull_request: pull_request:
jobs: jobs:
lint: lint:
uses: ./.github/workflows/lint.yml uses: ./.github/workflows/lint.yml
@@ -98,3 +93,7 @@ jobs:
uses: ./.github/workflows/jmeter.yml uses: ./.github/workflows/jmeter.yml
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
-7
View File
@@ -25,18 +25,11 @@ jobs:
make: pyright make: pyright
npm: true npm: true
prettier: prettier:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
with: with:
make: checkprettier make: checkprettier
npm: true npm: true
bundle:
uses: ./.github/workflows/make.yml
with:
make: checkbundle
npm: true
poetry: poetry:
uses: ./.github/workflows/poetry.yml uses: ./.github/workflows/poetry.yml
+1
View File
@@ -6,6 +6,7 @@ __pycache__
*$py.class *$py.class
.mypy_cache .mypy_cache
.vscode .vscode
.codex
*-lock.json *-lock.json
.python-version .python-version
+1
View File
@@ -0,0 +1 @@
min-release-age=7
+60
View File
@@ -0,0 +1,60 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"] CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+1
View File
@@ -66,6 +66,7 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest uv run pytest tests/regtest
test-migration: test-migration:
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px"> <img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" /> <img alt="lnbits_head" src="docs/assets/header.jpg" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source. LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px"> <img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
## LNbits extension universe ## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions. All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px"> <img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
## LNbits API ## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services. LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img src="https://i.imgur.com/V742sb9.png" style="width:800px"> <img alt="lnbits_api" src="docs/assets/api.jpg" />
## LNbits node manager ## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier. LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px"> <img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
## LNbits across all your devices ## LNbits merchant tools
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices. The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px"> <img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
## Powered by LNbits ## Powered by LNbits
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI # LNBits Admin UI
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table # Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316"> ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# Basic installation # Basic installation
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU) # LNbits Super User (SU)
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview # LNbits Roles: A Quick Overview
-1
View File
@@ -14,7 +14,6 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets # Backend wallets
+4
View File
@@ -65,6 +65,7 @@ from .middleware import (
InstalledExtensionMiddleware, InstalledExtensionMiddleware,
add_first_install_middleware, add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .tasks import internal_invoice_listener, invoice_listener, run_interval from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -196,6 +197,9 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app return app
+4
View File
@@ -12,6 +12,7 @@ from .extensions import (
drop_extension_db, drop_extension_db,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids, get_user_active_extensions_ids,
get_user_extension, get_user_extension,
get_user_extensions, get_user_extensions,
@@ -58,6 +59,7 @@ from .users import (
get_account_by_username, get_account_by_username,
get_account_by_username_or_email, get_account_by_username_or_email,
get_accounts, get_accounts,
get_accounts_count,
get_user, get_user,
get_user_access_control_lists, get_user_access_control_lists,
get_user_from_account, get_user_from_account,
@@ -117,11 +119,13 @@ __all__ = [
"get_account_by_username", "get_account_by_username",
"get_account_by_username_or_email", "get_account_by_username_or_email",
"get_accounts", "get_accounts",
"get_accounts_count",
"get_admin_settings", "get_admin_settings",
"get_db_version", "get_db_version",
"get_db_versions", "get_db_versions",
"get_installed_extension", "get_installed_extension",
"get_installed_extensions", "get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension", "get_latest_payments_by_extension",
"get_payment", "get_payment",
"get_payments", "get_payments",
+7
View File
@@ -90,6 +90,13 @@ async def get_installed_extensions(
return all_extensions return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension( async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None: ) -> UserExtension | None:
+5 -5
View File
@@ -292,6 +292,7 @@ async def create_payment(
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [], labels=data.labels or [],
external_id=data.external_id,
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
@@ -305,7 +306,7 @@ async def update_payment_checking_id(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
UPDATE apipayments UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')} SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
WHERE checking_id = :old_id WHERE checking_id = :old_id
""", # noqa: S608 """, # noqa: S608
{ {
@@ -320,13 +321,15 @@ async def update_payment(
payment: Payment, payment: Payment,
new_checking_id: str | None = None, new_checking_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> Payment:
payment.updated_at = datetime.now(timezone.utc) payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
) )
if new_checking_id and new_checking_id != payment.checking_id: if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn) await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history( async def get_payments_history(
@@ -398,7 +401,6 @@ async def get_payment_count_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentCountStat]: ) -> list[PaymentCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
extra_stmts = [] extra_stmts = []
@@ -431,7 +433,6 @@ async def get_daily_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -481,7 +482,6 @@ async def get_wallets_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentWalletStats]: ) -> list[PaymentWalletStats]:
if not filters: if not filters:
filters = Filters() filters = Filters()
+2 -1
View File
@@ -105,7 +105,8 @@ async def get_settings_field(
) )
if not row: if not row:
return None return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"]) value = json.loads(row["value"]) if row["value"] else None
return SettingsField(id=row["id"], value=value, tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"): async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
+7
View File
@@ -37,6 +37,13 @@ async def create_account(
return account return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await (conn or db).update("accounts", account)
+13
View File
@@ -802,3 +802,16 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status. Used for account activation status.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true") await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
+2
View File
@@ -25,6 +25,7 @@ from .payments import (
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
UpdatePaymentExtra,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -90,6 +91,7 @@ __all__ = [
"SimpleStatus", "SimpleStatus",
"TinyURL", "TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
+33
View File
@@ -13,6 +13,7 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentStatus, FiatPaymentStatus,
) )
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentStatus, PaymentStatus,
@@ -34,6 +35,11 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel): class PayInvoice(BaseModel):
payment_request: str payment_request: str
description: str | None = None description: str | None = None
@@ -53,6 +59,11 @@ class CreatePayment(BaseModel):
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel): class Payment(BaseModel):
@@ -77,6 +88,11 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -151,6 +167,7 @@ class PaymentFilters(FilterModel):
"status", "status",
"time", "time",
"labels", "labels",
"external_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
@@ -161,11 +178,13 @@ class PaymentFilters(FilterModel):
"memo", "memo",
"time", "time",
"tag", "tag",
"external_id",
] ]
status: str | None status: str | None
tag: str | None tag: str | None
checking_id: str | None checking_id: str | None
external_id: str | None
amount: int amount: int
fee: int fee: int
memo: str | None memo: str | None
@@ -249,6 +268,7 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@@ -263,6 +283,10 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported") raise ValueError("The provided unit is not supported")
return v return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel): class PaymentsStatusCount(BaseModel):
incoming: int = 0 incoming: int = 0
@@ -301,3 +325,12 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel): class UpdatePaymentLabels(BaseModel):
labels: list[str] = [] labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
+4 -2
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel from datetime import datetime, timezone
from pydantic import BaseModel, Field
class TinyURL(BaseModel): class TinyURL(BaseModel):
@@ -6,4 +8,4 @@ class TinyURL(BaseModel):
url: str url: str
endless: bool endless: bool
wallet: str wallet: str
time: float time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+127 -3
View File
@@ -1,7 +1,9 @@
import base64 import base64
import io import io
from urllib.parse import quote
from uuid import uuid4 from uuid import uuid4
import filetype
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
@@ -10,11 +12,48 @@ from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset from lnbits.core.models.assets import Asset
from lnbits.settings import settings from lnbits.settings import settings
IMAGE_MIME_TYPE_ALIASES = {
"heic": "image/heic",
"heics": "image/heics",
"heif": "image/heif",
"image/jpg": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
}
PIL_IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
}
INLINE_ASSET_MIME_TYPES = {
"image/heic",
"image/heics",
"image/heif",
"image/jpeg",
"image/png",
}
ASSET_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": (
"sandbox; default-src 'none'; script-src 'none'; "
"object-src 'none'; base-uri 'none'"
),
}
THUMBNAIL_FORMAT_MIME_TYPES = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
}
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset: async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type: if not file.content_type:
raise ValueError("File must have a content type.") raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
raise ValueError(f"File type '{file.content_type}' not allowed.") raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id): if not settings.is_unlimited_assets_user(user_id):
@@ -30,14 +69,26 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded." f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
) )
stored_mime_type = detect_image_mime_type(contents)
if stored_mime_type != content_type:
logger.warning(
"Image MIME type mismatch: declared={}, detected={}",
content_type,
stored_mime_type,
)
raise ValueError(
"Image file content does not match declared file type. "
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
)
thumb_buffer = thumbnail_from_bytes(contents) thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset( asset = Asset(
id=uuid4().hex, id=uuid4().hex,
user_id=user_id, user_id=user_id,
mime_type=file.content_type, mime_type=stored_mime_type,
is_public=is_public, is_public=is_public,
name=file.filename or "unnamed", name=filename,
size_bytes=len(contents), size_bytes=len(contents),
thumbnail_base64=( thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8") base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -51,6 +102,79 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
return asset return asset
def normalize_asset_mime_type(content_type: str) -> str:
content_type = content_type.split(";", 1)[0].strip().lower()
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
def normalize_media_type(media_type: str) -> str:
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
def thumbnail_media_type() -> str:
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
def content_disposition(disposition: str, filename: str) -> str:
safe_filename = filename or "unnamed"
quoted_filename = quote(safe_filename, safe="")
if quoted_filename == safe_filename:
return f'{disposition}; filename="{safe_filename}"'
return f"{disposition}; filename*=utf-8''{quoted_filename}"
def allowed_asset_mime_types() -> set[str]:
return {
mime_type
for mime_type in (
normalize_asset_mime_type(mime_type)
for mime_type in settings.lnbits_assets_allowed_mime_types
)
if mime_type.startswith("image/")
}
def detect_image_mime_type(contents: bytes) -> str:
kind = filetype.guess(contents)
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
verify_pil_image(contents, mime_type)
return mime_type
if mime_type and mime_type.startswith("image/"):
return mime_type
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if not mime_type:
raise ValueError("Image file content does not match declared file type.")
return mime_type
def verify_pil_image(contents: bytes, mime_type: str) -> None:
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if detected_mime_type != mime_type:
raise ValueError("Image file content does not match declared file type.")
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None: def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try: try:
image = Image.open(io.BytesIO(contents)) image = Image.open(io.BytesIO(contents))
+12
View File
@@ -9,6 +9,7 @@ from lnbits.core.crud import (
delete_installed_extension, delete_installed_extension,
get_db_version, get_db_version,
get_installed_extension, get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.crud.extensions import ( from lnbits.core.crud.extensions import (
@@ -38,6 +39,8 @@ async def install_extension(
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download: if not skip_download:
await ext_info.download_archive() await ext_info.download_archive()
@@ -63,6 +66,15 @@ async def install_extension(
return extension return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str): async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id) await stop_extension_background_work(ext_id)
+77
View File
@@ -2,6 +2,7 @@ import hashlib
import hmac import hmac
import json import json
import time import time
from base64 import b64encode
import httpx import httpx
from loguru import logger from loguru import logger
@@ -169,6 +170,82 @@ async def verify_paypal_webhook(headers, payload: bytes):
raise ValueError("PayPal webhook cannot be verified.") from exc raise ValueError("PayPal webhook cannot be verified.") from exc
def check_square_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
notification_url: str | None,
):
if not sig_header:
logger.warning("Square signature header is missing.")
raise ValueError("Square signature header is missing.")
if not secret:
logger.warning("Square webhook signature key is not set.")
raise ValueError("Square webhook cannot be verified.")
if not notification_url:
logger.warning("Square webhook notification URL is not set.")
raise ValueError("Square webhook cannot be verified.")
signed_payload = notification_url.encode() + payload
computed_signature = b64encode(
hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).digest()
).decode()
if hmac.compare_digest(computed_signature, sig_header) is not True:
logger.warning("Square signature verification failed.")
raise ValueError("Square signature verification failed.")
def check_revolut_signature(
payload: bytes,
sig_header: str | None,
timestamp_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Revolut signature header is missing.")
raise ValueError("Revolut signature header is missing.")
if not timestamp_header:
logger.warning("Revolut timestamp header is missing.")
raise ValueError("Revolut timestamp header is missing.")
if not secret:
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
digest = hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"v1={digest}"
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.
+44 -7
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@@ -64,6 +64,7 @@ async def pay_invoice(
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
@@ -97,6 +98,7 @@ async def pay_invoice(
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels, labels=labels,
external_id=external_id,
) )
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -169,15 +171,15 @@ async def create_fiat_invoice(
internal_payment.fiat_provider = fiat_provider_name internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent # TODO: move to payment
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = ( new_checking_id = (
f"fiat_{fiat_provider_name}_" f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}" f"{fiat_invoice.checking_id or internal_payment.checking_id}"
) )
await update_payment(internal_payment, new_checking_id, conn=conn) internal_payment = await update_payment(
internal_payment.checking_id = new_checking_id internal_payment, new_checking_id, conn=conn
)
return internal_payment return internal_payment
@@ -217,6 +219,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
labels=data.labels, labels=data.labels,
external_id=data.external_id,
conn=conn, conn=conn,
) )
@@ -258,6 +261,7 @@ async def create_invoice(
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -342,6 +346,7 @@ async def create_invoice(
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels, labels=labels,
external_id=external_id,
) )
payment = await create_payment( payment = await create_payment(
@@ -369,7 +374,7 @@ async def update_pending_payment(
status = await check_payment_status(payment) status = await check_payment_status(payment)
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) payment = await update_payment(payment, conn=conn)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status, conn=conn)
return payment return payment
@@ -509,6 +514,8 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id) await internal_invoice_queue_put(payment.checking_id)
@@ -819,6 +826,8 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task( task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
@@ -867,7 +876,7 @@ async def update_payment_success_status(
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat)) payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn) payment = await update_payment(payment, conn=conn)
return payment return payment
@@ -1068,3 +1077,31 @@ async def _send_payment_notification_in_background(
if not wallet: if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed") raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment) send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
payment = await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
+18
View File
@@ -23,6 +23,7 @@ from ..crud import (
get_account_by_email, get_account_by_email,
get_account_by_pubkey, get_account_by_pubkey,
get_account_by_username, get_account_by_username,
get_accounts_count,
get_super_settings, get_super_settings,
get_user_extensions, get_user_extensions,
get_user_from_account, get_user_from_account,
@@ -55,6 +56,8 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None, conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account: if account:
account.validate_fields() account.validate_fields()
if account.username and await get_account_by_username( if account.username and await get_account_by_username(
@@ -95,6 +98,15 @@ async def create_user_account_no_ckeck(
return user return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account: async def update_user_account(account: Account) -> Account:
account.validate_fields() account.validate_fields()
@@ -173,6 +185,12 @@ async def check_admin_settings():
if account and account.extra and account.extra.provider == "env": if account and account.extra and account.extra.provider == "env":
settings.first_install = True settings.first_install = True
if settings.has_first_install_token_changed():
logger.warning("First install token is changed. Resetting admin settings.")
new_settings = await init_admin_settings()
settings.super_user = new_settings.super_user
settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}
-3
View File
@@ -1,3 +0,0 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}
+25 -9
View File
@@ -16,7 +16,14 @@ from lnbits.core.crud.assets import (
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import create_user_asset from lnbits.core.services.assets import (
ASSET_SECURITY_HEADERS,
INLINE_ASSET_MIME_TYPES,
content_disposition,
create_user_asset,
normalize_media_type,
thumbnail_media_type,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_account_id_exists,
@@ -75,11 +82,7 @@ async def api_get_asset_data(
if not asset: if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response( return asset_response(asset.data, asset.mime_type, asset.name)
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get( @asset_router.get(
@@ -101,14 +104,14 @@ async def api_get_asset_thumbnail(
if not asset_info: if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response( return asset_response(
content=( content=(
base64.b64decode(asset_info.thumbnail_base64) base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64 if asset_info.thumbnail_base64
else b"" else b""
), ),
media_type=asset_info.mime_type, media_type=thumbnail_media_type(),
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'}, filename=asset_info.name,
) )
@@ -172,3 +175,16 @@ async def api_delete_asset(
await delete_user_asset(account_id.id, asset_id) await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.") return SimpleStatus(success=True, message="Asset deleted successfully.")
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
media_type = normalize_media_type(media_type)
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
return Response(
content=content,
media_type=media_type,
headers={
**ASSET_SECURITY_HEADERS,
"Content-Disposition": content_disposition(disposition, filename),
},
)
+20 -2
View File
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.crud.users import ( from lnbits.core.crud.users import (
get_user_access_control_lists, get_user_access_control_lists,
update_user_access_control_list, update_user_access_control_list,
@@ -35,6 +36,7 @@ from lnbits.decorators import (
check_account_exists, check_account_exists,
check_admin, check_admin,
check_user_exists, check_user_exists,
optional_user_id,
) )
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@@ -319,7 +321,10 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider( async def login_with_sso_provider(
request: Request, provider: str, user_id: str | None = None request: Request,
provider: str,
user_id: str | None,
auth_user_id: str | None = Depends(optional_user_id),
): ):
provider_sso = _new_sso(provider) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
@@ -327,6 +332,8 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.", f"Login by '{provider}' not allowed.",
) )
if user_id and user_id != auth_user_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token" provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso: with provider_sso:
@@ -347,7 +354,11 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
userinfo = await provider_sso.verify_and_process(request) userinfo = await provider_sso.verify_and_process(request)
if not userinfo: if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
user_id = decrypt_internal_message(provider_sso.state) if provider_sso.state is None or provider_sso.state == "null":
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None) request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id) return await _handle_sso_login(userinfo, user_id)
@@ -548,6 +559,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
account.hash_password(data.password) account.hash_password(data.password)
await update_account(account) await update_account(account)
settings.first_install = False settings.first_install = False
# only confrm it after the super user has been successfully updated
if settings.first_install_token:
settings.first_install_token_confirmed = data.first_install_token
await set_settings_field(
"first_install_token_confirmed", data.first_install_token
)
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
+428 -1
View File
@@ -4,17 +4,30 @@ from fastapi import APIRouter, Request
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment, get_standalone_payment,
update_payment,
) )
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status, check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook, verify_paypal_webhook,
) )
from lnbits.core.services.payments import create_fiat_invoice from lnbits.core.services.payments import (
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -50,6 +63,41 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "square":
payload = await request.body()
sig_header = request.headers.get("x-square-hmacsha256-signature")
check_square_signature(
payload,
sig_header,
settings.square_webhook_signature_key,
settings.square_payment_webhook_url,
)
event = await request.json()
await handle_square_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "revolut":
payload = await request.body()
sig_header = request.headers.get("Revolut-Signature")
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
check_revolut_signature(
payload,
sig_header,
timestamp_header,
settings.revolut_webhook_signing_secret,
)
event = await request.json()
await handle_revolut_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@@ -280,3 +328,382 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e: except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}") logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions() return FiatSubscriptionPaymentOptions()
async def handle_square_event(event: dict):
event_id = event.get("event_id") or event.get("id", "")
event_type = event.get("type", "")
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
if event_type == "payment.updated":
await _handle_square_payment_event(event)
return
if event_type == "invoice.payment_made":
await _handle_square_invoice_payment_made(event)
return
logger.warning(f"Unhandled Square event type: '{event_type}'.")
async def handle_revolut_event(event: dict):
event_type = event.get("event", "")
order_id = event.get("order_id")
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
if not order_id:
logger.warning("Revolut event missing order_id.")
return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if payment:
await check_fiat_status(payment)
return
if event_type == "ORDER_COMPLETED":
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
return
if event_type == "SUBSCRIPTION_INITIATED":
logger.info("Revolut subscription initiated event received.")
return
if event_type in [
"SUBSCRIPTION_CANCELLED",
"SUBSCRIPTION_FINISHED",
"SUBSCRIPTION_OVERDUE",
]:
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
return
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict,
fiat_provider: RevolutWallet,
order_id: str | None = None,
order: dict | None = None,
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return
reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference:
logger.warning("Revolut subscription event missing LNbits metadata.")
return
if not order_id:
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment:
if existing_payment.external_id != subscription_id:
existing_payment.external_id = subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
if not order:
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency:
raise ValueError("Revolut subscription order missing amount or currency.")
extra = {
**(reference.extra or {}),
"subscription_request_id": reference.subscription_request_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
"checking_id": f"order_{order_id}",
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
amount_minor=amount_minor,
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
order_type = (order.get("type") or "").lower()
order_state = (order.get("state") or "").upper()
if order_type != "payment" or order_state != "COMPLETED":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription(
subscription, fiat_provider, order_id=order_id, order=order
)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency,
amount=amount,
memo=memo,
extra=extra,
internal=True,
external_id=subscription_id,
),
)
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut"
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict):
payment = _square_extract_payment(event)
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if payment_options.wallet_id:
if not _square_payment_is_completed(payment):
logger.debug("Square subscription payment is not completed yet.")
return
await _handle_square_subscription_payment(payment, payment_options)
return
order_id = payment.get("order_id")
if not order_id:
logger.warning("Square payment event missing order_id.")
return
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
if not lnbits_payment:
logger.warning(f"No payment found for Square order: '{order_id}'.")
return
await check_fiat_status(lnbits_payment)
async def _handle_square_invoice_payment_made(event: dict):
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
order_id = invoice.get("order_id")
if not order_id:
logger.warning("Square invoice.payment_made event missing order_id.")
return
subscription_id = invoice.get("subscription_id")
fiat_provider = await get_fiat_provider("square")
if not isinstance(fiat_provider, SquareWallet):
logger.warning("Square fiat provider is not configured.")
return
payment = await fiat_provider.get_payment_for_order(order_id)
if not payment:
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
return
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
else None
)
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
logger.warning("Square subscription payment missing LNbits metadata.")
return
await _handle_square_subscription_payment(
payment,
payment_options,
invoice.get("public_url") or "",
square_subscription_id=subscription_id,
)
async def _handle_square_subscription_payment(
payment: dict,
payment_options: FiatSubscriptionPaymentOptions,
payment_request: str = "",
square_subscription_id: str | None = None,
):
amount_money = payment.get("amount_money") or {}
amount = amount_money.get("amount")
currency = (amount_money.get("currency") or "").upper()
payment_id = payment.get("id")
if amount is None or not currency or not payment_id:
raise ValueError("Square subscription payment event missing payment amount.")
wallet_id = payment_options.wallet_id
if not wallet_id:
raise ValueError("Square subscription payment event missing wallet_id.")
checking_id = f"payment_{payment_id}"
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
if existing_payment:
if (
square_subscription_id
and existing_payment.external_id != square_subscription_id
):
existing_payment.external_id = square_subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
square_subscription_id = square_subscription_id or (
payment_options.extra or {}
).get("square_subscription_id")
extra = {
**(payment_options.extra or {}),
"subscription_request_id": payment_options.subscription_request_id,
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": checking_id,
"payment_request": payment_request,
},
}
lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount / 100,
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
await check_fiat_status(lnbits_payment)
def _square_payment_options_from_payment(
payment: Payment,
) -> FiatSubscriptionPaymentOptions:
extra = payment.extra or {}
return FiatSubscriptionPaymentOptions(
wallet_id=payment.wallet_id,
tag=extra.get("tag") or payment.tag,
subscription_request_id=extra.get("subscription_request_id"),
extra=extra,
memo=payment.memo,
)
def _square_extract_payment(event: dict) -> dict:
event_object = event.get("data", {}).get("object", {})
return event_object.get("payment") or event_object
def _square_payment_is_completed(payment: dict) -> bool:
return (payment.get("status") or "").upper() == "COMPLETED"
def _square_payment_note(payment: dict) -> str:
return payment.get("note") or payment.get("payment_note") or ""
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
if not isinstance(meta, list):
return FiatSubscriptionPaymentOptions()
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+6 -2
View File
@@ -98,12 +98,16 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = ( detail = (
str(exc) str(exc)
if isinstance(exc, AssertionError) if isinstance(exc, (AssertionError, ValueError))
else f"Failed to install extension '{ext_info.id}'." else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})." f"({ext_info.installed_version})."
) )
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=(
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
detail=detail, detail=detail,
) from exc ) from exc
+67 -1
View File
@@ -2,17 +2,35 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from loguru import logger from loguru import logger
from pydantic import BaseModel
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services import update_cached_settings
from lnbits.core.services.fiat_providers import test_connection from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import StripeWallet, get_fiat_provider from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat") fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
class RevolutCreateWebhook(BaseModel):
url: str
endpoint: str | None = None
api_secret_key: str | None = None
api_version: str | None = None
class RevolutCreateWebhookResponse(BaseModel):
id: str | None = None
url: str
events: list[str] = []
signing_secret: str
already_exists: bool = False
@fiat_router.put( @fiat_router.put(
"/check/{provider}", "/check/{provider}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
@@ -22,6 +40,54 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider) return await test_connection(provider)
@fiat_router.post(
"/revolut/webhook",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_create_revolut_webhook(
data: RevolutCreateWebhook,
) -> RevolutCreateWebhookResponse:
try:
webhook = await RevolutWallet.create_webhook(
url=data.url,
endpoint=data.endpoint,
api_secret_key=data.api_secret_key,
api_version=data.api_version,
)
except ValueError as exc:
logger.warning(exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=500, detail="Failed to create Revolut webhook."
) from exc
signing_secret = webhook.get("signing_secret")
webhook_url = webhook.get("url") or data.url
if not signing_secret:
raise HTTPException(
status_code=502, detail="Revolut returned no webhook signing secret."
)
updated_settings = {
"revolut_payment_webhook_url": webhook_url,
"revolut_webhook_signing_secret": signing_secret,
}
for key, value in updated_settings.items():
await set_settings_field(key, value)
update_cached_settings(updated_settings)
return RevolutCreateWebhookResponse(
id=webhook.get("id"),
url=webhook_url,
events=webhook.get("events") or [],
signing_secret=signing_secret,
already_exists=webhook.get("already_exists", False),
)
@fiat_router.post( @fiat_router.post(
"/{provider}/subscription", "/{provider}/subscription",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
+2 -2
View File
@@ -200,7 +200,7 @@ async def index(
) -> HTMLResponse: ) -> HTMLResponse:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
"index.html", "base.html",
{ {
"user": user.json(), "user": user.json(),
}, },
@@ -211,7 +211,7 @@ async def index(
@generic_router.get("/node/public") @generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) @generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse: async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "index.html", {"public": True}) return template_renderer().TemplateResponse(request, "base.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
+34
View File
@@ -34,6 +34,7 @@ from lnbits.core.models import (
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
UpdatePaymentExtra,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -263,6 +264,7 @@ async def api_payments_create(
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels, labels=invoice_data.labels,
external_id=invoice_data.external_id,
) )
return payment return payment
@@ -296,6 +298,38 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.") return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)
+4 -2
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else: else:
if not database_uri.startswith("postgres://"): if not database_uri.startswith("postgres://"):
raise ValueError( raise ValueError(
"Please use the 'postgres://...' " "format for the database URL." "Please use the 'postgres://...' format for the database URL."
) )
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
@@ -560,7 +560,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str: def pagination(self) -> str:
stmt = "" stmt = ""
self.limit = self.limit or 10 if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
stmt += f"LIMIT {min(1000, self.limit)} " stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset: if self.offset:
stmt += f"OFFSET {self.offset}" stmt += f"OFFSET {self.offset}"
+6
View File
@@ -9,6 +9,8 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings from lnbits.settings import settings
from .paypal import PayPalWallet from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat") fiat_module = importlib.import_module("lnbits.fiat")
@@ -17,6 +19,8 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum): class FiatProviderType(Enum):
stripe = "StripeWallet" stripe = "StripeWallet"
paypal = "PayPalWallet" paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None: async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -52,5 +56,7 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [ __all__ = [
"PayPalWallet", "PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet", "StripeWallet",
] ]
+7 -3
View File
@@ -95,6 +95,10 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request." description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.", "If not provided, one will be generated.",
) )
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field( tag: str | None = Field(
default=None, default=None,
description="Payments created by the recurring subscription" description="Payments created by the recurring subscription"
@@ -127,15 +131,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus): class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True paid = True # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentFailedStatus(FiatPaymentStatus): class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False paid = False # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentPendingStatus(FiatPaymentStatus): class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None paid = None # type: ignore[reportIncompatibleVariableOverride]
class FiatProvider(ABC): class FiatProvider(ABC):
+637
View File
@@ -0,0 +1,637 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class RevolutCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class RevolutCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: RevolutCheckoutOptions | None = None
class RevolutSubscriptionReference(BaseModel):
wallet_id: str
tag: str | None = None
subscription_request_id: str | None = None
extra: dict[str, Any] | None = None
memo: str | None = None
REVOLUT_WEBHOOK_EVENTS = [
"ORDER_AUTHORISED",
"ORDER_COMPLETED",
"SUBSCRIPTION_INITIATED",
]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant"""
def __init__(self):
logger.debug("Initializing RevolutWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.revolut_api_endpoint:
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
if not settings.revolut_api_secret_key:
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
"Revolut-Api-Version": settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("RevolutWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Revolut wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
)
payload = {
"amount": amount_minor,
"currency": currency.upper(),
"description": checkout.description or memo or "LNbits Invoice",
"redirect_url": success_url,
"metadata": {
**checkout.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
},
}
try:
r = await self.client.post(
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
checkout_url = data.get("checkout_url")
if not order_id or not checkout_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=checkout_url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if quantity != 1:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions do not support quantity.",
)
wallet_id = payment_options.wallet_id
if not wallet_id:
return FiatSubscriptionResponse(
ok=False, error_message="Wallet ID is required."
)
extra = payment_options.extra or {}
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
reference = RevolutSubscriptionReference(
wallet_id=wallet_id,
tag=payment_options.tag,
subscription_request_id=payment_options.subscription_request_id,
extra=extra,
memo=payment_options.memo,
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
),
}
if extra.get("trial_duration"):
payload["trial_duration"] = extra["trial_duration"]
headers = {
**self.headers,
"Idempotency-Key": payment_options.subscription_request_id,
}
try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post(
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
revolut_subscription_id = data.get("id")
setup_order_id = data.get("setup_order_id")
if not revolut_subscription_id or not setup_order_id:
return FiatSubscriptionResponse(
ok=False,
error_message=(
"Server error: missing subscription id or setup order id"
),
)
setup_order = await self.get_order(setup_order_id)
checkout_url = setup_order.get("checkout_url")
if not checkout_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing setup checkout url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Revolut does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
order_id = self._normalize_revolut_id(checking_id)
return self._status_from_order(await self.get_order(order_id))
except Exception as exc:
logger.debug(f"Error getting Revolut invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Revolut does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Revolut does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _normalize_revolut_id(self, checking_id: str) -> str:
value = (
checking_id.replace("fiat_revolut_", "", 1)
if checking_id.startswith("fiat_revolut_")
else checking_id
)
return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription_cycle(
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
@classmethod
async def create_webhook(
cls,
url: str,
endpoint: str | None = None,
api_secret_key: str | None = None,
api_version: str | None = None,
) -> dict[str, Any]:
if not url:
raise ValueError("Missing Revolut webhook URL.")
cls._validate_webhook_url(url)
if not endpoint and not settings.revolut_api_endpoint:
raise ValueError("Missing Revolut API endpoint.")
if not api_secret_key and not settings.revolut_api_secret_key:
raise ValueError("Missing Revolut API secret key.")
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
secret_key = api_secret_key or settings.revolut_api_secret_key
headers = {
"Authorization": f"Bearer {secret_key}",
"Revolut-Api-Version": api_version or settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
webhooks = await cls._list_webhooks(client)
existing = await cls._get_existing_webhook(client, webhooks, url)
if existing:
existing["already_exists"] = True
return existing
response = await client.post(
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
@classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
for field in ["webhooks", "data", "items"]:
if isinstance(data.get(field), list):
return data[field]
return []
@classmethod
async def _get_existing_webhook(
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
) -> dict[str, Any] | None:
for webhook in webhooks:
if cls._normalize_webhook_url(webhook.get("url")) != (
cls._normalize_webhook_url(url)
):
continue
webhook_id = webhook.get("id")
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
webhook = response.json()
events = set(webhook.get("events") or [])
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
if missing_events:
raise ValueError(
"A Revolut webhook already exists for this URL, but it is "
f"missing required events: {', '.join(sorted(missing_events))}."
)
if not webhook.get("signing_secret"):
raise ValueError(
"A Revolut webhook already exists for this URL, but Revolut "
"did not return a signing secret."
)
return webhook
return None
@classmethod
def _normalize_webhook_url(cls, url: str | None) -> str:
return (url or "").strip().rstrip("/")
@classmethod
def _validate_webhook_url(cls, url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if parsed.scheme not in ["http", "https"] or not hostname:
raise ValueError("Revolut webhook URL must be a clearnet URL.")
host = hostname.lower()
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
if host.endswith(".local") or host.endswith(".onion"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None:
try:
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Revolut options: {e}")
return None
def _serialize_subscription_reference(
self, reference: RevolutSubscriptionReference
) -> str:
payload = reference.dict(exclude_none=True)
serialized = json.dumps(payload, separators=(",", ":"))
if len(serialized) > 1024:
raise ValueError("Revolut subscription external_reference is too long.")
return serialized
def deserialize_subscription_reference(
self, external_reference: str | None
) -> RevolutSubscriptionReference | None:
if not external_reference:
return None
try:
return RevolutSubscriptionReference.parse_obj(
json.loads(external_reference)
)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning(exc)
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.revolut_api_endpoint),
str(settings.revolut_api_secret_key),
str(settings.revolut_api_version),
str(settings.revolut_webhook_signing_secret),
]
)
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
+620
View File
@@ -0,0 +1,620 @@
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
View File
@@ -55,7 +55,6 @@ def static_url_for(static: str, path: str) -> str:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates: def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = [ folders = [
"lnbits/templates", "lnbits/templates",
"lnbits/core/templates",
settings.extension_builder_working_dir_path.as_posix(), settings.extension_builder_working_dir_path.as_posix(),
] ]
+14
View File
@@ -7,6 +7,7 @@ from typing import Any
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
@@ -246,3 +247,16 @@ def add_first_install_middleware(app: FastAPI):
): ):
return RedirectResponse("/first_install") return RedirectResponse("/first_install")
return await call_next(request) return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+71 -10
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg") lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by") lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field( lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.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"
) # sneaky sneaky ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -300,6 +300,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True) lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True) lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False) lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings): class OpsSettings(LNbitsSettings):
@@ -323,10 +324,6 @@ class AssetSettings(LNbitsSettings):
"heic", "heic",
"heif", "heif",
"heics", "heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
] ]
) )
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0) lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -447,6 +444,7 @@ class SecuritySettings(LNbitsSettings):
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0) lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0) lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
first_install_token_confirmed: str | None = Field(default=None)
def is_wallet_max_balance_exceeded(self, amount): def is_wallet_max_balance_exceeded(self, amount):
return ( return (
@@ -587,6 +585,8 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings): class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/") phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None) phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
@@ -699,6 +699,35 @@ class PayPalFiatProvider(LNbitsSettings):
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class SquareFiatProvider(LNbitsSettings):
square_enabled: bool = Field(default=False)
square_api_endpoint: str = Field(default="https://connect.squareup.com")
square_access_token: str | None = Field(default=None)
square_location_id: str | None = Field(default=None)
square_api_version: str = Field(default="2026-01-22")
square_payment_success_url: str = Field(default="https://lnbits.com")
square_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
)
square_webhook_signature_key: str | None = Field(default=None)
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class RevolutFiatProvider(LNbitsSettings):
revolut_enabled: bool = Field(default=False)
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
revolut_api_secret_key: str | None = Field(default=None)
revolut_api_version: str = Field(default="2026-04-20")
revolut_payment_success_url: str = Field(default="https://lnbits.com")
revolut_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
)
revolut_webhook_signing_secret: str | None = Field(default=None)
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -736,7 +765,12 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider): class FiatProvidersSettings(
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
def is_fiat_provider_enabled(self, provider: str | None) -> bool: def is_fiat_provider_enabled(self, provider: str | None) -> bool:
""" """
Checks if a specific fiat provider is enabled. Checks if a specific fiat provider is enabled.
@@ -747,6 +781,10 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
return self.stripe_enabled return self.stripe_enabled
if provider == "paypal": if provider == "paypal":
return self.paypal_enabled return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]: def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -766,6 +804,18 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
): ):
allowed_providers.append("paypal") allowed_providers.append("paypal")
if self.square_enabled and (
not self.square_limits.allowed_users
or user_id in self.square_limits.allowed_users
):
allowed_providers.append("square")
if self.revolut_enabled and (
not self.revolut_limits.allowed_users
or user_id in self.revolut_limits.allowed_users
):
allowed_providers.append("revolut")
return allowed_providers return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -997,7 +1047,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
class Config: class Config(EditableSettings.Config):
extra = Extra.forbid extra = Extra.forbid
@@ -1005,8 +1055,8 @@ class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False) debug: bool = Field(default=False)
debug_database: bool = Field(default=False) debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP. # When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True) auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1") host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0) port: int = Field(default=5000, gt=0)
@@ -1025,11 +1075,20 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0) cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits" return self.lnbits_extensions_path == "lnbits"
def has_first_install_token_changed(self) -> bool:
if not self.first_install_token:
return False
if not settings.first_install_token_confirmed:
return False
return self.first_install_token != settings.first_install_token_confirmed
def check_auth_secret_key(self): def check_auth_secret_key(self):
if self.auth_secret_key: if self.auth_secret_key:
return return
@@ -1139,11 +1198,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config: class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
case_sensitive = False case_sensitive = False
json_loads = list_parse_fallback json_loads = list_parse_fallback # type: ignore[assignment]
def is_user_allowed(self, user_id: str) -> bool: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
@@ -1223,6 +1282,7 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded") default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient") default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow") default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field() denomination: str | None = Field()
extensions: list[str] = Field() extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies") allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1286,6 +1346,7 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded, defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient, defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow, defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination, denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids), extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies, allowedCurrencies=settings.lnbits_allowed_currencies,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -18
View File
File diff suppressed because one or more lines are too long
+14 -4
View File
@@ -212,12 +212,15 @@ body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */ backdrop-filter: none; /* Ensure the page content is not affected */
} }
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
}
body.body--dark .q-header, body.body--dark .q-header,
body.body--dark .q-drawer { body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3); --q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8); backdrop-filter: brightness(0.8);
} }
body.rounded-ui .q-card, body.rounded-ui .q-card,
@@ -388,11 +391,18 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
} }
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18)); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
} }
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45)); 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;
} }
:root { :root {
+15
View File
@@ -267,6 +267,15 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:', webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description: webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.', 'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
webhook_square_description:
'On the Square side configure a webhook pointing to this exact LNbits URL.',
square_webhook_url_hint:
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
access_token: 'Access Token',
location_id: 'Location ID',
square_location_id_hint:
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
api_version: 'API Version',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@@ -275,6 +284,8 @@ window.localisation.en = {
requires_server_restart: requires_server_restart:
'Changing these settings requires a server restart to take effect.', 'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet', funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.', latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
notifications_configure: 'Configure Notifications', notifications_configure: 'Configure Notifications',
@@ -479,6 +490,8 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards', toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow', card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards', toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language', language: 'Language',
assets: 'Assets', assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)', max_asset_size_mb: 'Max Asset Size (MB)',
@@ -803,6 +816,8 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.', webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description: webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.', 'On the PayPal side configure a webhook pointing to your LNbits server.',
square_webhook_signature_key_hint:
'Square webhook signature key used to verify incoming events.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

+3 -1
View File
@@ -452,7 +452,9 @@ window.app.component('username-password', {
confirmationMethod: 'code', confirmationMethod: 'code',
confirmationEmail: '', confirmationEmail: '',
confirmationCode: this.invitationCode || '', confirmationCode: this.invitationCode || '',
showConfirmationCode: false showConfirmationCode: false,
showPwd: false,
showPwdRepeat: false
} }
}, },
methods: { methods: {
@@ -5,6 +5,9 @@ window.app.component('lnbits-admin-fiat-providers', {
return { return {
formAddStripeUser: '', formAddStripeUser: '',
formAddPaypalUser: '', formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true hideInputToggle: true
} }
}, },
@@ -20,6 +23,12 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url || this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal') this.calculateWebhookUrl('paypal')
) )
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
} }
}, },
watch: { watch: {
@@ -58,6 +67,8 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() { syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe') this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal') this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
}, },
maybeSetWebhookUrl(fieldName, provider) { maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) { if (!this.formData) {
@@ -77,6 +88,47 @@ window.app.component('lnbits-admin-fiat-providers', {
} }
this.copyText(url) this.copyText(url)
}, },
isClearnetWebhookUrl(url) {
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (e) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false
}
if (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
host.endsWith('.onion')
) {
return false
}
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host === '0.0.0.0' ||
host === '::1'
) {
return false
}
return true
},
notifyRevolutWebhookWarning(message) {
Quasar.Notify.create({
type: 'warning',
message,
icon: null,
closeBtn: true
})
},
addStripeAllowedUser() { addStripeAllowedUser() {
const addUser = this.formAddStripeUser || '' const addUser = this.formAddStripeUser || ''
if ( if (
@@ -111,6 +163,40 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users = this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user) this.formData.paypal_limits.allowed_users.filter(u => u !== user)
}, },
addSquareAllowedUser() {
const addUser = this.formAddSquareUser || ''
if (
addUser.length &&
!this.formData.square_limits.allowed_users.includes(addUser)
) {
this.formData.square_limits.allowed_users = [
...this.formData.square_limits.allowed_users,
addUser
]
this.formAddSquareUser = ''
}
},
removeSquareAllowedUser(user) {
this.formData.square_limits.allowed_users =
this.formData.square_limits.allowed_users.filter(u => u !== user)
},
addRevolutAllowedUser() {
const addUser = this.formAddRevolutUser || ''
if (
addUser.length &&
!this.formData.revolut_limits.allowed_users.includes(addUser)
) {
this.formData.revolut_limits.allowed_users = [
...this.formData.revolut_limits.allowed_users,
addUser
]
this.formAddRevolutUser = ''
}
},
removeRevolutAllowedUser(user) {
this.formData.revolut_limits.allowed_users =
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) { checkFiatProvider(providerName) {
LNbits.api LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`) .request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -124,6 +210,48 @@ window.app.component('lnbits-admin-fiat-providers', {
}) })
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
createRevolutWebhook() {
const webhookUrl = this.calculateWebhookUrl('revolut')
this.formData.revolut_payment_webhook_url = webhookUrl
if (!this.formData.revolut_api_secret_key) {
this.notifyRevolutWebhookWarning(
'Add your Revolut API secret key before creating a webhook.'
)
return
}
if (!this.isClearnetWebhookUrl(webhookUrl)) {
this.notifyRevolutWebhookWarning(
'Revolut webhook URL must be a clearnet URL.'
)
return
}
this.creatingRevolutWebhook = true
LNbits.api
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
url: webhookUrl,
endpoint: this.formData.revolut_api_endpoint,
api_secret_key: this.formData.revolut_api_secret_key,
api_version: this.formData.revolut_api_version
})
.then(response => {
const data = response.data
this.formData.revolut_payment_webhook_url = data.url
this.formData.revolut_webhook_signing_secret = data.signing_secret
Quasar.Notify.create({
type: 'positive',
message: `Revolut webhook ${
data.already_exists ? 'already exists' : 'created'
}${data.id ? `: ${data.id}` : ''}.`,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.creatingRevolutWebhook = false
})
} }
} }
}) })
@@ -202,7 +202,18 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd', 'Phoenixd',
{ {
phoenixd_api_endpoint: 'Endpoint', phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key' phoenixd_api_password: 'Key',
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
} }
], ],
[ [
@@ -41,5 +41,37 @@ window.app.component('lnbits-admin-site-customisation', {
] ]
} }
}, },
methods: {} methods: {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}) })
@@ -360,18 +360,37 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
exportCSV(detailed = false) { async exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
const pagination = this.paymentsTable.pagination const pagination = this.paymentsTable.pagination
const query = { const maxPages = 100
sortby: pagination.sortBy ?? 'time', const limit = 1000
direction: pagination.descending ? 'desc' : 'asc' let payments = []
}
const params = new URLSearchParams(query) this.paymentsCSV.loading = true
LNbits.api.getPayments(this.wallet, params).then(response => { try {
let payments = response.data.data.map(this.mapPayment) 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
}
}
let columns = this.paymentsCSV.columns let columns = this.paymentsCSV.columns
if (detailed) { if (detailed) {
@@ -400,7 +419,11 @@ window.app.component('lnbits-payment-list', {
payments, payments,
this.wallet.name + '-payments' this.wallet.name + '-payments'
) )
}) } catch (err) {
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
}, },
addFilterTag() { addFilterTag() {
if (!this.exportTagName) return if (!this.exportTagName) return
@@ -8,6 +8,10 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: { prefix: {
type: String, type: String,
default: 'lnurlp' default: 'lnurlp'
},
href: {
type: String,
default: ''
} }
}, },
data() { data() {
@@ -21,7 +25,10 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') { if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url) const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes) const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}` this.lnurl =
this.href && this.href.trim() !== ''
? `${this.href}?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') { } else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) { if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://') this.lnurl = this.url.replace('http://', this.prefix + '://')
@@ -85,6 +85,9 @@ window.app.component('lnbits-qrcode', {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return false return false
} else if (this.href && this.href.startsWith('http')) {
window.open(this.href, '_blank')
event.preventDefault()
} }
}, },
async writeNfcTag() { async writeNfcTag() {
@@ -165,5 +168,26 @@ window.app.component('lnbits-qrcode', {
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px' this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
this.$refs.qrCode.$el.setAttribute('width', '100%') this.$refs.qrCode.$el.setAttribute('width', '100%')
this.$refs.qrCode.$el.removeAttribute('height') this.$refs.qrCode.$el.removeAttribute('height')
},
computed: {
optimizedValue() {
const separatorIndex = this.value.indexOf(':')
const type =
separatorIndex === -1 ? '' : this.value.substring(0, separatorIndex)
const value =
separatorIndex === -1
? this.value
: this.value.substring(separatorIndex + 1)
if (this.utils.isValidBech32(value)) {
const normalizedValue = value.toUpperCase()
if (type) {
return `${type.toUpperCase()}:${normalizedValue}`
}
return normalizedValue
}
return this.value
}
} }
}) })
@@ -69,6 +69,14 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow') document.body.classList.remove('card-shadow')
} }
}, },
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) { 'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val) this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) { if (val === true) {
@@ -150,6 +158,9 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) { if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow') document.body.classList.add('card-shadow')
} }
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') { if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image') document.body.classList.add('bg-image')
document.body.style.setProperty( document.body.style.setProperty(
+6 -5
View File
@@ -1,16 +1,17 @@
function eventReaction(amount) { function eventReaction(amount) {
localUrl = '' localUrl = ''
reaction = localStorage.getItem('lnbits.reactions') const reaction =
if (!reaction || reaction === 'None') { Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction
if (!reaction || reaction.toLowerCase() === 'none') {
return return
} }
try { try {
if (amount < 0) { if (amount < 0) {
return return
} }
reaction = localStorage.getItem('lnbits.reactions') if (typeof window[reaction] === 'function') {
if (reaction) { window[reaction]()
window[reaction.split('|')[1]]()
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
+4
View File
@@ -29,6 +29,10 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient SETTINGS.defaultCardGradient
), ),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow), cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction), reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore( bgimageChoice: localStore(
'lnbits.backgroundImage', 'lnbits.backgroundImage',
+39 -9
View File
@@ -10,6 +10,10 @@ window.PageAccount = {
name: 'bitcoin', name: 'bitcoin',
color: 'deep-orange' color: 'deep-orange'
}, },
{
name: 'classic',
color: 'purple'
},
{ {
name: 'mint', name: 'mint',
color: 'green' color: 'green'
@@ -541,31 +545,56 @@ window.PageAccount = {
if (file) { if (file) {
this.uploadAsset(file) this.uploadAsset(file)
} }
e.target.value = null
}, },
async uploadAsset(file) { 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} = {}
) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
try { try {
await LNbits.api.request( const {data} = await LNbits.api.request(
'POST', 'POST',
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`, `/api/v1/assets?public_asset=${isPublic}`,
null, null,
formData, formData,
{ {
headers: {'Content-Type': 'multipart/form-data'} headers: {'Content-Type': 'multipart/form-data'}
} }
) )
this.$q.notify({ if (notifySuccess) {
type: 'positive', this.$q.notify({
message: 'Upload successful!', type: 'positive',
icon: null message: 'Upload successful!',
}) icon: null
})
}
await this.getUserAssets() await this.getUserAssets()
return data
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) { async deleteAsset(asset) {
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?') .confirmDialog('Are you sure you want to delete this asset?')
@@ -703,7 +732,8 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark, darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded, cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient, cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow cardShadowChoice: this.g.settings.defaultCardShadow,
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
} }
this.siteCustomisationChanged(defaults) this.siteCustomisationChanged(defaults)
} }
+1 -1
View File
@@ -21,7 +21,7 @@ window.PageHome = {
return ( return (
this.lnurl !== '' && this.lnurl !== '' &&
this.g.settings.allowRegister && this.g.settings.allowRegister &&
'user-id-only' in this.g.settings.authMethods this.g.settings.authMethods.includes('user-id-only')
) )
}, },
formatDescription() { formatDescription() {
+40
View File
@@ -160,6 +160,46 @@ window._lnbitsUtils = {
return null return null
} }
}, },
isValidBech32(value) {
if (typeof value !== 'string') {
return false
}
const candidate = value.trim()
if (
!candidate ||
(candidate !== candidate.toLowerCase() &&
candidate !== candidate.toUpperCase())
) {
return false
}
const normalized = candidate.toLowerCase()
const splitPosition = normalized.lastIndexOf('1')
if (splitPosition <= 0) {
return false
}
const humanReadablePart = normalized.substring(0, splitPosition)
const data = normalized.substring(splitPosition + 1)
if (data.length < 6) {
return false
}
if (
typeof bech32ToFiveBitArray !== 'function' ||
typeof verify_checksum !== 'function'
) {
return false
}
const words = bech32ToFiveBitArray(data)
if (words.some(word => word < 0)) {
return false
}
return verify_checksum(humanReadablePart, words)
},
async notifyApiError(error) { async notifyApiError(error) {
if (!error.response) { if (!error.response) {
return console.error(error) return console.error(error)
+6 -2
View File
@@ -58,11 +58,15 @@ body.bg-image {
} }
// transparent background for specific elements // transparent background for specific elements
body.body--dark { body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--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-header, .q-header,
.q-drawer { .q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)}; --q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8); backdrop-filter: brightness(0.8);
} }
} }
+11 -2
View File
@@ -61,12 +61,21 @@ body.rounded-ui {
body.card-shadow { body.card-shadow {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18)); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
} }
} }
body.card-shadow.body--dark { body.card-shadow.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45)); 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;
} }
} }
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
/*
* 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;
}
+1466 -955
View File
File diff suppressed because it is too large Load Diff
+2773 -3121
View File
File diff suppressed because it is too large Load Diff
+222 -132
View File
@@ -1,5 +1,5 @@
/*! /*!
* qrcode.vue v3.6.0 * qrcode.vue v3.9.0
* A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3 * A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3
* © 2017-PRESENT @scopewu(https://github.com/scopewu) * © 2017-PRESENT @scopewu(https://github.com/scopewu)
* MIT License. * MIT License.
@@ -909,7 +909,18 @@ var qrcodegen;
})(qrcodegen || (qrcodegen = {})); })(qrcodegen || (qrcodegen = {}));
var QR = 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 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 = { var ErrorCorrectLevelMap = {
L: QR.QrCode.Ecc.LOW, L: QR.QrCode.Ecc.LOW,
M: QR.QrCode.Ecc.MEDIUM, M: QR.QrCode.Ecc.MEDIUM,
@@ -929,74 +940,139 @@ var SUPPORTS_PATH2D = (function () {
function validErrorCorrectLevel(level) { function validErrorCorrectLevel(level) {
return level in ErrorCorrectLevelMap; 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) { function generatePath(modules, margin) {
if (margin === void 0) { margin = 0; } if (margin === void 0) { margin = 0; }
var ops = []; var pathSegments = [];
modules.forEach(function (row, y) { for (var y = 0; y < modules.length; y++) {
var row = modules[y];
var start = null; var start = null;
row.forEach(function (cell, x) { for (var x = 0; x < row.length; x++) {
var cell = row[x];
if (!cell && start !== null) { if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma, // M0 0h7v1H0z injects the space with the move and drops the comma,
// saving a char per operation pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
start = null; start = null;
return; continue;
} }
// end of row, clean up or skip // end of row, clean up or skip
if (x === row.length - 1) { if (x === row.length - 1) {
if (!cell) { if (!cell) {
// We would have closed the op above already so this can only mean // We would have closed the op above already so this can only mean
// 2+ light modules in a row. // 2+ light modules in a row.
return; continue;
} }
if (start === null) { if (start === null) {
// Just a single dark module. // Just a single dark module.
ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z")); pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
} }
else { else {
// Otherwise finish the current line. // Otherwise finish the current line.
ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z")); pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
} }
return; continue;
} }
if (cell && start === null) { if (cell && start === null) {
start = x; start = x;
} }
}); }
}); }
return ops.join(''); return pathSegments.join('');
} }
function getImageSettings(cells, size, margin, imageSettings) { function getImageSettings(cells, size, margin, imageSettings) {
var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y; var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y;
var numCells = cells.length + margin * 2; var numCells = cells.length + margin * 2;
var defaultSize = Math.floor(size * 0.1); var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO);
var scale = numCells / size; var scale = numCells / size;
var w = (width || defaultSize) * scale; var w = (width || defaultSize) * scale;
var h = (height || defaultSize) * scale; var h = (height || defaultSize) * scale;
var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale; var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale;
var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale; var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale;
var excavation = null; var borderRadius = (imageSettings.borderRadius || 0) * scale;
if (imageSettings.excavate) { return { x: x, y: y, h: h, w: w, borderRadius: borderRadius };
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 excavateModules(modules, excavation) { function useQRCode(props) {
return modules.slice().map(function (row, y) { var margin = vue.computed(function () { var _a; return ((_a = props.margin) !== null && _a !== void 0 ? _a : DEFAULT_MARGIN) >>> 0; });
if (y < excavation.y || y >= excavation.y + excavation.h) { var cells = vue.computed(function () {
return row; var level = validErrorCorrectLevel(props.level) ? props.level : defaultErrorCorrectLevel;
} return QR.QrCode.encodeText(props.value, ErrorCorrectLevelMap[level]).getModules();
return row.map(function (cell, x) {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
}); });
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);
}
return generatePath(cells.value, margin.value);
});
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 = { var QRCodeProps = {
value: { value: {
@@ -1006,7 +1082,7 @@ var QRCodeProps = {
}, },
size: { size: {
type: Number, type: Number,
default: 100, default: DEFAULT_QR_SIZE,
}, },
level: { level: {
type: String, type: String,
@@ -1024,7 +1100,7 @@ var QRCodeProps = {
margin: { margin: {
type: Number, type: Number,
required: false, required: false,
default: 0, default: DEFAULT_MARGIN,
}, },
imageSettings: { imageSettings: {
type: Object, type: Object,
@@ -1052,6 +1128,12 @@ var QRCodeProps = {
required: false, required: false,
default: '#fff', 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: { var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: {
type: String, type: String,
@@ -1063,36 +1145,11 @@ var QrcodeSvg = vue.defineComponent({
name: 'QRCodeSvg', name: 'QRCodeSvg',
props: QRCodeProps, props: QRCodeProps,
setup: function (props) { setup: function (props) {
var numCells = vue.ref(0); var _a = useQRCode(props), numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var fgPath = vue.ref(''); var uid = getUid();
var imageProps; var qrGradientId = "qrcode.vue-gradient-".concat(uid);
var generate = function () { var qrLogoClipPathId = "qrcode.vue-logo-clip-path-".concat(uid);
var value = props.value, _level = props.level, _margin = props.margin; var gradientVNode = vue.computed(function () {
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) if (!props.gradient)
return null; return null;
var gradientProps = props.gradientType === 'linear' var gradientProps = props.gradientType === 'linear'
@@ -1109,7 +1166,7 @@ var QrcodeSvg = vue.defineComponent({
fx: '50%', fx: '50%',
fy: '50%', fy: '50%',
}; };
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [ return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [
vue.h('stop', { vue.h('stop', {
offset: '0%', offset: '0%',
style: { stopColor: props.gradientStartColor }, style: { stopColor: props.gradientStartColor },
@@ -1119,27 +1176,52 @@ var QrcodeSvg = vue.defineComponent({
style: { stopColor: props.gradientEndColor }, style: { stopColor: props.gradientEndColor },
}), }),
]); ]);
}; });
generate(); var clipPathVNode = vue.computed(function () {
vue.onUpdated(generate); 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,
}),
]);
});
return function () { return vue.h('svg', { return function () { return vue.h('svg', {
width: props.size, width: props.size,
height: props.size, height: props.size,
'shape-rendering': "crispEdges",
xmlns: 'http://www.w3.org/2000/svg', xmlns: 'http://www.w3.org/2000/svg',
viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value), viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value),
role: 'img',
'aria-label': props.value,
}, [ }, [
vue.h('defs', {}, [renderGradient()]), vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]),
vue.h('rect', { vue.h('rect', {
width: '100%', width: '100%',
height: '100%', height: '100%',
fill: props.background, fill: props.background,
}), }),
vue.h('path', { vue.h('path', {
fill: props.gradient ? 'url(#qr-gradient)' : props.foreground, fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground,
d: fgPath.value, d: fgPath.value,
}), }),
props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)), 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, ")") } : {}))),
]); }; ]); };
}, },
}); });
@@ -1147,81 +1229,89 @@ var QrcodeCanvas = vue.defineComponent({
name: 'QRCodeCanvas', name: 'QRCodeCanvas',
props: QRCodeProps, props: QRCodeProps,
setup: function (props, ctx) { 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 canvasEl = vue.ref(null);
var imageRef = 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 generate = function () { var generate = function () {
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 size = props.size, 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; var canvas = canvasEl.value;
if (!canvas) { if (!canvas) {
return; return;
} }
var ctx = canvas.getContext('2d'); var canvasCtx = canvas.getContext('2d');
if (!ctx) { if (!canvasCtx) {
return; return;
} }
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules(); var image = imageEl.value;
var numCells = cells.length + margin * 2; var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
var image = imageRef.value; var scale = (size / numCells.value) * devicePixelRatio;
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; canvas.height = canvas.width = size * devicePixelRatio;
ctx.scale(scale, scale); canvasCtx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.fillStyle = background; canvasCtx.fillStyle = background;
ctx.fillRect(0, 0, numCells, numCells); canvasCtx.fillRect(0, 0, numCells.value, numCells.value);
if (gradient) { if (gradient) {
var grad = void 0; var grad = void 0;
if (gradientType === 'linear') { if (gradientType === 'linear') {
grad = ctx.createLinearGradient(0, 0, numCells, numCells); grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value);
} }
else { else {
grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2); grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2);
} }
grad.addColorStop(0, gradientStartColor); grad.addColorStop(0, gradientStartColor);
grad.addColorStop(1, gradientEndColor); grad.addColorStop(1, gradientEndColor);
ctx.fillStyle = grad; canvasCtx.fillStyle = grad;
} }
else { else {
ctx.fillStyle = foreground; canvasCtx.fillStyle = foreground;
} }
if (SUPPORTS_PATH2D) { if (SUPPORTS_PATH2D) {
ctx.fill(new Path2D(generatePath(cells, margin))); canvasCtx.fill(new Path2D(fgPath.value));
} }
else { else {
cells.forEach(function (row, rdx) { cells.value.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) { row.forEach(function (cell, cdx) {
if (cell) { if (cell) {
ctx.fillRect(cdx + margin, rdx + margin, 1, 1); canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1);
} }
}); });
}); });
} }
if (showImage) { var showImage = props.imageSettings.src && image && image.naturalWidth !== 0 && image.naturalHeight !== 0;
ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height); 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);
}
} }
}; };
vue.onMounted(generate); vue.onMounted(generate);
vue.onUpdated(generate); vue.watchEffect(generate);
var style = ctx.attrs.style;
return function () { return vue.h(vue.Fragment, [ return function () { return vue.h(vue.Fragment, [
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })), 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") }) })),
props.imageSettings.src && vue.h('img', { props.imageSettings.src && vue.h('img', {
ref: imageRef, ref: imageEl,
src: props.imageSettings.src, src: props.imageSettings.src,
style: { display: 'none' }, style: { display: 'none' },
onLoad: generate, onLoad: generate,
@@ -1231,23 +1321,23 @@ var QrcodeCanvas = vue.defineComponent({
}); });
var QrcodeVue = vue.defineComponent({ var QrcodeVue = vue.defineComponent({
name: 'Qrcode', name: 'Qrcode',
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, 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,
}); };
},
}); });
exports.QrcodeCanvas = QrcodeCanvas; exports.QrcodeCanvas = QrcodeCanvas;
+15 -28
View File
@@ -43,8 +43,7 @@ summary {
abbr[title] { abbr[title] {
border-bottom: none; border-bottom: none;
text-decoration: underline; text-decoration: underline;
-webkit-text-decoration: underline dotted; text-decoration: underline dotted;
text-decoration: underline dotted;
} }
/** /**
@@ -200,8 +199,7 @@ input[type=search]::-webkit-search-decoration {
.material-symbols-outlined, .material-symbols-outlined,
.material-symbols-rounded, .material-symbols-rounded,
.material-symbols-sharp { .material-symbols-sharp {
-webkit-user-select: none; user-select: none;
user-select: none;
cursor: inherit; cursor: inherit;
font-size: inherit; font-size: inherit;
display: inline-flex; display: inline-flex;
@@ -998,8 +996,7 @@ input[type=search]::-webkit-search-decoration {
height: 1px; height: 1px;
} }
.q-checkbox__bg, .q-checkbox__icon-container { .q-checkbox__bg, .q-checkbox__icon-container {
-webkit-user-select: none; user-select: none;
user-select: none;
} }
.q-checkbox__bg { .q-checkbox__bg {
top: 25%; top: 25%;
@@ -2212,8 +2209,7 @@ body.q-ios-padding .q-dialog__inner > div {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
outline: 0 !important; outline: 0 !important;
-webkit-user-select: auto; user-select: auto;
user-select: auto;
} }
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill { .q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
-webkit-animation-name: q-autofill; -webkit-animation-name: q-autofill;
@@ -3039,8 +3035,7 @@ body.body--dark .q-knob--editable:focus:before {
z-index: 2001; z-index: 2001;
height: 100%; height: 100%;
width: 15px; width: 15px;
-webkit-user-select: none; user-select: none;
user-select: none;
} }
.q-layout, .q-header, .q-footer, .q-page { .q-layout, .q-header, .q-footer, .q-page {
@@ -3297,8 +3292,7 @@ body.platform-ios .q-layout--containerized {
height: 1px; height: 1px;
} }
.q-radio__bg, .q-radio__icon-container { .q-radio__bg, .q-radio__icon-container {
-webkit-user-select: none; user-select: none;
user-select: none;
} }
.q-radio__bg { .q-radio__bg {
top: 25%; top: 25%;
@@ -3782,8 +3776,7 @@ body.platform-ios:not(.native-mobile) .q-dialog__inner--top .q-select__dialog--f
.q-slide-item__content { .q-slide-item__content {
background: inherit; background: inherit;
transition: transform 0.2s ease-in; transition: transform 0.2s ease-in;
-webkit-user-select: none; user-select: none;
user-select: none;
cursor: pointer; cursor: pointer;
} }
@@ -4156,8 +4149,7 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
} }
.q-splitter__separator { .q-splitter__separator {
background-color: rgba(0, 0, 0, 0.12); background-color: rgba(0, 0, 0, 0.12);
-webkit-user-select: none; user-select: none;
user-select: none;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -4246,8 +4238,7 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
color: #000; color: #000;
} }
.q-stepper__tab--navigation { .q-stepper__tab--navigation {
-webkit-user-select: none; user-select: none;
user-select: none;
cursor: pointer; cursor: pointer;
} }
.q-stepper__tab--active, .q-stepper__tab--done { .q-stepper__tab--active, .q-stepper__tab--done {
@@ -4479,8 +4470,7 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
.q-table th { .q-table th {
font-weight: 500; font-weight: 500;
font-size: 12px; font-size: 12px;
-webkit-user-select: none; user-select: none;
user-select: none;
} }
.q-table th.sortable { .q-table th.sortable {
cursor: pointer; cursor: pointer;
@@ -5484,8 +5474,7 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no
width: 0.5em; width: 0.5em;
height: 0.5em; height: 0.5em;
transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1); transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-user-select: none; user-select: none;
user-select: none;
z-index: 0; z-index: 0;
} }
.q-toggle__thumb:after { .q-toggle__thumb:after {
@@ -10337,6 +10326,7 @@ body.body--dark .inset-shadow-down {
.glossy { .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; 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 { .q-placeholder::placeholder {
color: inherit; color: inherit;
opacity: 0.7; opacity: 0.7;
@@ -10368,8 +10358,7 @@ body.body--dark .inset-shadow-down {
text-decoration: none; text-decoration: none;
} }
.q-link--focusable:focus-visible { .q-link--focusable:focus-visible {
-webkit-text-decoration: underline dashed currentColor 1px; text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
} }
body.electron .q-electron-drag { body.electron .q-electron-drag {
@@ -10386,8 +10375,7 @@ img.responsive {
} }
.non-selectable { .non-selectable {
-webkit-user-select: none !important; user-select: none !important;
user-select: none !important;
} }
.scroll, .scroll,
@@ -11045,8 +11033,7 @@ body.q-ios-padding .fullscreen {
} }
.q-touch { .q-touch {
-webkit-user-select: none; user-select: none;
user-select: none;
user-drag: none; user-drag: none;
-khtml-user-drag: none; -khtml-user-drag: none;
-webkit-user-drag: none; -webkit-user-drag: none;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+11 -35
View File
@@ -5,12 +5,8 @@ from collections.abc import Callable, Coroutine
from loguru import logger from loguru import logger
from lnbits.core.crud import ( from lnbits.core.models import Payment
get_standalone_payment, from lnbits.core.services.payments import update_invoice_callback
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@@ -116,7 +112,10 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running: while settings.lnbits_running:
checking_id = await internal_invoice_queue.get() checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}") logger.info(f"got an internal payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id, is_internal=True) payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None: async def invoice_listener() -> None:
@@ -129,7 +128,10 @@ async def invoice_listener() -> None:
funding_source = get_funding_source() funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream(): async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}") logger.info(f"got a payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id) payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
def wait_for_paid_invoices( def wait_for_paid_invoices(
@@ -165,33 +167,7 @@ def run_interval(
return wrapper return wrapper
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False): async def invoice_callback_dispatcher(payment: Payment):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
from lnbits.core.services.payments import check_payment_status
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
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items(): for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`") logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment) await send_chan.put(payment)
+7 -2
View File
@@ -82,9 +82,8 @@
<!-- scripts libraries --> <!-- scripts libraries -->
{% for url in INCLUDED_JS %} {% for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script> <script src="{{ static_url_for('static', url) }}"></script>
{% endfor %} {% endfor %} {% if user %}
<!-- user init --> <!-- user init -->
{% if user %}
<script> <script>
window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }})); window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }}));
{% if not public %} {% if not public %}
@@ -92,6 +91,12 @@
{% endif %} {% endif %}
</script> </script>
{% endif %} {% endif %}
<!-- app init -->
<script>
window.app = Vue.createApp({
el: '#vue'
})
</script>
<!-- scripts from extensions --> <!-- scripts from extensions -->
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<!-- components js --> <!-- components js -->
+35 -5
View File
@@ -774,7 +774,13 @@ include('components/lnbits-error.vue') %}
v-model="password" v-model="password"
name="password" name="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
type="password" :type="showPwd ? 'text' : 'password'"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input> ></q-input>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
@@ -803,16 +809,28 @@ include('components/lnbits-error.vue') %}
filled filled
v-model="password" v-model="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
type="password" :type="showPwd ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_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>
<q-input <q-input
dense dense
filled filled
v-model="passwordRepeat" v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'" :label="$t('password_repeat') + ' *'"
type="password" :type="showPwdRepeat ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_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> ></q-input>
<div <div
v-if="confirmationMethodsCount > 1" v-if="confirmationMethodsCount > 1"
@@ -925,16 +943,28 @@ include('components/lnbits-error.vue') %}
filled filled
v-model="password" v-model="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
type="password" :type="showPwd ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_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>
<q-input <q-input
dense dense
filled filled
v-model="passwordRepeat" v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'" :label="$t('password_repeat') + ' *'"
type="password" :type="showPwdRepeat ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_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> ></q-input>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
@@ -587,12 +587,544 @@
<q-item-section> Square </q-item-section> <q-item-section> Square </q-item-section>
<q-item-section side> <q-item-section side>
<div class="row items-center">Disabled</div> <div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.square_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section> </q-item-section>
</template> </template>
<q-card> <q-card class="q-pb-xl">
<q-card-section> Coming Soon </q-card-section> <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>
</q-expansion-item> </q-expansion-item>
</q-list> </q-list>
@@ -640,9 +1172,22 @@
> >
</div> </div>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px"> <div class="text-bold" style="min-width: 140px">Square</div>
Square (coming soon) <q-chip dense color="positive" text-color="white" icon="check"
</div> >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>
<q-chip dense color="positive" text-color="white" icon="check" <q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip >Checkout</q-chip
> >
@@ -653,7 +1198,7 @@
>Tap-to-pay</q-chip >Tap-to-pay</q-chip
> >
<q-chip dense color="grey-9" text-color="white" icon="public" <q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Global</q-chip >Regions: Revolut-supported countries</q-chip
> >
</div> </div>
</div> </div>
+29 -29
View File
@@ -75,7 +75,35 @@
</p> </p>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <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="col-12"> <div class="col-12">
<h6 class="q-my-none"> <h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span> <span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -182,34 +210,6 @@
></q-input> ></q-input>
</div> </div>
</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> <q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p> <p v-text="$t('watchdog')"></p>
@@ -52,6 +52,7 @@
:label="prop.label" :label="prop.label"
:hint="prop.hint" :hint="prop.hint"
:value="prop.value" :value="prop.value"
:readonly="prop.readonly || false"
> >
<q-btn <q-btn
v-if="prop.copy" v-if="prop.copy"
@@ -73,6 +74,15 @@
></q-btn> ></q-btn>
</q-input> </q-input>
</div> </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> </div>
<q-expansion-item <q-expansion-item
v-if=" v-if="
@@ -251,10 +251,27 @@
type="text" type="text"
v-model="formData.lnbits_default_bgimage" v-model="formData.lnbits_default_bgimage"
label="Background Image" 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." 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> </q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md q-mb-md"> <div class="row q-col-gutter-md q-mb-md">
@@ -303,6 +320,15 @@
> >
</q-toggle> </q-toggle>
</div> </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>
</div> </div>
</q-card-section> </q-card-section>
@@ -12,7 +12,7 @@
> >
<qrcode-vue <qrcode-vue
ref="qrCode" ref="qrCode"
:value="value" :value="optimizedValue"
:margin="margin" :margin="margin"
:size="size" :size="size"
level="Q" level="Q"
+1 -3
View File
@@ -1,6 +1,4 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with {% extends "base.html" %} {% block page_container %}
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block
page_container %}
<lnbits-error <lnbits-error
code="{{ status_code | safe }}" code="{{ status_code | safe }}"
message="{{ message | safe }}" message="{{ message | safe }}"
+2 -6
View File
@@ -1,9 +1,5 @@
{% macro window_vars(user) -%} {% macro window_vars(user) -%}
<script> <script>
//Needed for Vue to create the app on first load (although called on every page, its only loaded once) // deprecated dont use window_vars anymore
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin]
})
</script> </script>
{%- endmacro %} {%- endmacro %}
+160 -70
View File
@@ -492,10 +492,28 @@
siteCustomisationChanged({bgimageChoice: $event}) siteCustomisationChanged({bgimageChoice: $event})
" "
> >
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip <q-tooltip
><span v-text="$t('background_image')"></span ><span v-text="$t('background_image')"></span
></q-tooltip> ></q-tooltip>
</q-input> </q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div> </div>
</div> </div>
<div class="row q-mb-md"> <div class="row q-mb-md">
@@ -583,6 +601,30 @@
</div> </div>
</div> </div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('burger_menu_background')"></span>
</div>
<div class="col-8">
<q-toggle
dense
flat
round
icon="menu_open"
v-model="g.burgerMenuChoice"
@update:model-value="
siteCustomisationChanged({burgerMenuChoice: $event})
"
>
<q-tooltip
><span
v-text="$t('toggle_burger_menu_background')"
></span
></q-tooltip>
</q-toggle>
</div>
</div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col-4"> <div class="col-4">
<span v-text="$t('toggle_darkmode')"></span> <span v-text="$t('toggle_darkmode')"></span>
@@ -1108,82 +1150,130 @@
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<q-btn-dropdown <div
color="grey" class="row items-center no-wrap q-col-gutter-sm"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
> >
<q-list> <div class="col">
<q-item <q-btn-dropdown
clickable color="grey"
v-close-popup dense
@click="copyAssetLinkToClipboard(props.row)" outline
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
> >
<q-item-section avatar> <q-list>
<q-avatar icon="content_copy" /> <q-item
</q-item-section> clickable
<q-item-section> v-close-popup
<q-item-label>Copy Link</q-item-label> @click="
<q-item-label caption copyAssetLinkToClipboard(props.row)
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="toggleAssetPublicAccess(props.row)"
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
" "
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
> >
</q-item-section> <q-item-section avatar>
<q-item-section v-else> <q-avatar icon="content_copy" />
<q-item-label>Publish</q-item-label> </q-item-section>
<q-item-label caption <q-item-section>
>Make this asset public</q-item-label <q-item-label>Copy Link</q-item-label>
> <q-item-label caption
</q-item-section> >Copy asset link to
</q-item> clipboard</q-item-label
>
</q-item-section>
</q-item>
<q-item <q-item
clickable clickable
v-close-popup v-close-popup
@click="deleteAsset(props.row)" @click="
> toggleAssetPublicAccess(props.row)
<q-item-section avatar> "
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
> >
</q-item-section> <q-item-section avatar>
</q-item> <q-avatar
</q-list> :icon="
</q-btn-dropdown> props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
+1 -1
View File
@@ -136,7 +136,7 @@
</q-card> </q-card>
</div> </div>
<div <div
v-show="chartData.showPaymentStatus" v-show="chartData.showPaymentTags"
class="col-lg-3 col-md-6 col-sm-12 text-center" class="col-lg-3 col-md-6 col-sm-12 text-center"
> >
<q-card class="q-pt-sm"> <q-card class="q-pt-sm">
+38
View File
@@ -394,6 +394,44 @@
<span v-text="$t('pay_with', {provider: 'PayPal'})"></span> <span v-text="$t('pay_with', {provider: 'PayPal'})"></span>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('square')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('square')"
:active="receive.fiatProvider === 'square'"
@click="receive.fiatProvider = 'square'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<q-img src="/static/images/square_logo.png"></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Square'})"></span>
</q-item-section>
</q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('revolut')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('revolut')"
:active="receive.fiatProvider === 'revolut'"
@click="receive.fiatProvider = 'revolut'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Revolut'})"></span>
</q-item-section>
</q-item>
</q-list> </q-list>
</div> </div>

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