Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ff997ec1 | ||
|
|
eb55170905 | ||
|
|
dc466ae738 | ||
|
|
dd5fe8b7a2 | ||
|
|
5196ee6a15 | ||
|
|
3498cfb5b8 | ||
|
|
30ffe437a0 | ||
|
|
574641d432 | ||
|
|
90d14629d9 | ||
|
|
3be06269c2 | ||
|
|
d7723696d8 | ||
|
|
91f7b7730d | ||
|
|
b2aaf97cf3 | ||
|
|
8e0d1cc100 | ||
|
|
cb3fd56647 | ||
|
|
9f383bfee6 | ||
|
|
3b2e28dd60 | ||
|
|
132192bc94 | ||
|
|
281c3df826 | ||
|
|
08591f34c2 | ||
|
|
f0e8ae0f5c | ||
|
|
168cb726b1 | ||
|
|
91ac245307 | ||
|
|
d098e2f710 | ||
|
|
a0f65f4cda | ||
|
|
606ee215b4 | ||
|
|
7716f819f6 | ||
|
|
8325d880cb | ||
|
|
b090470fc1 | ||
|
|
c7297d2e77 | ||
|
|
b6e111b21c | ||
|
|
7747d7b741 | ||
|
|
f3a5a8e002 | ||
|
|
157a6485b4 | ||
|
|
8867b27b09 | ||
|
|
68b607ecbc | ||
|
|
f411fd13dc | ||
|
|
baee90da67 | ||
|
|
661b713993 | ||
|
|
5a5f253fa5 | ||
|
|
77a5d7fe50 | ||
|
|
07dd4fc685 | ||
|
|
3761f7922c | ||
|
|
327b9d7f63 | ||
|
|
cacffc67ee | ||
|
|
b7fdf99a8d | ||
|
|
cd6cfff9cf | ||
|
|
7b635a31e5 | ||
|
|
b4c0cdbc7c | ||
|
|
fd765e2060 | ||
|
|
5f86627eae | ||
|
|
3af3838995 | ||
|
|
a762529fef | ||
|
|
245569d0b9 | ||
|
|
fcaeb0ac7a | ||
|
|
d2aedde21b | ||
|
|
5adc419c74 | ||
|
|
5d79327906 | ||
|
|
71e0b396d2 | ||
|
|
89d673448a | ||
|
|
aed3f3b569 | ||
|
|
d9a2a7bb95 | ||
|
|
15ede13104 | ||
|
|
62047ea758 | ||
|
|
e5e589fc40 | ||
|
|
46406792fe | ||
|
|
dbf71fed53 | ||
|
|
850087a8ec | ||
|
|
d9b045c526 | ||
|
|
e1ca6ef82a | ||
|
|
17c40d539e | ||
|
|
625fa6503c | ||
|
|
73634e5161 | ||
|
|
1fe35070f4 | ||
|
|
ad268516a9 | ||
|
|
ca94909aab | ||
|
|
b3efb4d378 | ||
|
|
5213508dc1 | ||
|
|
af9331eede | ||
|
|
9b8fe42102 | ||
|
|
a0e4a59454 | ||
|
|
eefaf3c50c | ||
|
|
89cabda123 | ||
|
|
6122a03f32 | ||
|
|
ad8b70a098 | ||
|
|
6449276003 | ||
|
|
4da651b74a | ||
|
|
b5f3a46feb | ||
|
|
1057b4693f | ||
|
|
5711b4b804 | ||
|
|
704e5a1b73 | ||
|
|
e6ca8a33c6 | ||
|
|
9c257aa23d | ||
|
|
ef752cbeca | ||
|
|
a509eb8fdb | ||
|
|
49cc8104fc | ||
|
|
f1f6af0e35 | ||
|
|
5f1cfc0e37 | ||
|
|
730ab59578 | ||
|
|
c8bdba8e53 | ||
|
|
c6c67c52db | ||
|
|
da31e3caaa | ||
|
|
d0bf47163b | ||
|
|
37ad515427 | ||
|
|
8c77f75cf1 | ||
|
|
21505471d5 | ||
|
|
baa9a35773 | ||
|
|
7f114ddcc0 | ||
|
|
92aad20dd7 | ||
|
|
0f4ae5da86 | ||
|
|
7a796c6510 | ||
|
|
0910687328 | ||
|
|
33e2fc2ea8 | ||
|
|
0c6e8394c8 | ||
|
|
d55e2a0e1f | ||
|
|
148ba9d275 | ||
|
|
d2ca774f6f | ||
|
|
233398b512 | ||
|
|
152c1dbb74 | ||
|
|
44985cb0d1 | ||
|
|
b1a7692ce4 | ||
|
|
31bff37b1e | ||
|
|
3f7a1798d5 | ||
|
|
98d4dbab8b | ||
|
|
7c7a04da9d | ||
|
|
3ccefb70fa | ||
|
|
5346ac47b1 | ||
|
|
9f4ed53888 | ||
|
|
faac56c14c | ||
|
|
3e0d0d9896 | ||
|
|
b9a004a5e4 | ||
|
|
da3cb548bd | ||
|
|
4144359617 | ||
|
|
07268b87d2 | ||
|
|
c356874bcb | ||
|
|
f9b5c7db7a | ||
|
|
20f9ddb57c | ||
|
|
ee57ba4c1a | ||
|
|
ff7f5c1ca5 | ||
|
|
1463d75ee2 | ||
|
|
f1fc4710ee | ||
|
|
00eaec8290 | ||
|
|
28c4235c94 | ||
|
|
b7d178c08e | ||
|
|
9d0ec97d39 | ||
|
|
b6f533be24 | ||
|
|
5e36bb4fcd | ||
|
|
f9b0604711 | ||
|
|
39c33699af | ||
|
|
c89721223f | ||
|
|
1e6e97c12d | ||
|
|
783efb19db | ||
|
|
9ffc63b5dc | ||
|
|
0ec8139e5c | ||
|
|
a8c3181852 | ||
|
|
e038ceb9be | ||
|
|
b4eccb9e5d | ||
|
|
691b7ad055 | ||
|
|
c84d1b66c6 | ||
|
|
cf7726ddfd | ||
|
|
bd50a3c546 | ||
|
|
c9270bbf7f | ||
|
|
6474aeb982 | ||
|
|
37ba437ad1 | ||
|
|
35af181e24 | ||
|
|
f8c58aef0e | ||
|
|
8755984bd8 | ||
|
|
0d5661cda7 | ||
|
|
c2a3fbc6c0 | ||
|
|
6bac34fc6b | ||
|
|
b54eedee84 | ||
|
|
bd07f7a5ef | ||
|
|
a40306f5cd | ||
|
|
2fecec2623 | ||
|
|
4ad89396cc | ||
|
|
82307c4839 | ||
|
|
8506199c2f | ||
|
|
d142d76148 | ||
|
|
98e9a61b98 | ||
|
|
877af0c21e | ||
|
|
dd27b190f3 | ||
|
|
2856803ca7 | ||
|
|
ca8264b1f5 | ||
|
|
39ca9da870 | ||
|
|
2b603bdc48 | ||
|
|
26780df065 | ||
|
|
248fcc06ab | ||
|
|
f794373d30 | ||
|
|
87f25ea715 | ||
|
|
b9de754598 | ||
|
|
785fb7af8e | ||
|
|
55c5ab3a6d |
@@ -23,3 +23,10 @@ mypy.ini
|
||||
package-lock.json
|
||||
package.json
|
||||
pytest.ini
|
||||
|
||||
.mypy_cache
|
||||
.github
|
||||
.pytest_cache
|
||||
.vscode
|
||||
bin
|
||||
dist
|
||||
|
||||
@@ -18,7 +18,6 @@ ENABLE_LOG_TO_FILE=true
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
|
||||
@@ -187,7 +186,6 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
@@ -333,4 +331,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
######################################
|
||||
###### Logging and Development #######
|
||||
######################################
|
||||
|
||||
|
||||
@@ -2,7 +2,26 @@ name: Build LNbits AppImage
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
@@ -75,7 +94,7 @@ jobs:
|
||||
# Build AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ github.event.release.tag_name }}
|
||||
TAG_NAME=${{ inputs.tag_name }}
|
||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||
./appimagetool-x86_64.AppImage \
|
||||
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||
@@ -93,7 +112,7 @@ jobs:
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
upload_url: ${{ inputs.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
@@ -12,6 +12,13 @@ jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
test-frontend:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: test-frontend
|
||||
npm: true
|
||||
|
||||
test-api:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
@@ -75,7 +82,14 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
- LndWallet
|
||||
- CoreLightningWallet
|
||||
- CoreLightningRestWallet
|
||||
- LNbitsWallet
|
||||
- EclairWallet
|
||||
with:
|
||||
custom-pytest: "uv run pytest tests/regtest"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -62,3 +62,4 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
build-args: LNBITS_TAG=${{ inputs.tag }}
|
||||
|
||||
@@ -40,8 +40,8 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest
|
||||
sudo chmod -R a+rwx .
|
||||
|
||||
- name: Run pytest
|
||||
@@ -63,6 +63,8 @@ jobs:
|
||||
LNBITS_ENDPOINT: http://localhost:5001
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
|
||||
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
ECLAIR_PASS: lnbits
|
||||
|
||||
@@ -10,7 +10,29 @@ permissions:
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github pre-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --prerelease --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -19,6 +41,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
@@ -30,3 +53,10 @@ jobs:
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -13,6 +13,8 @@ jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -21,8 +23,17 @@ jobs:
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -32,6 +43,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -41,6 +53,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
@@ -56,3 +69,6 @@ jobs:
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: tests
|
||||
name: tests pytest
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -23,7 +23,7 @@ repos:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.6.2
|
||||
rev: v3.7.4
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
FROM boltz/boltz-client:latest AS boltz
|
||||
ARG LNBITS_TAG=latest
|
||||
|
||||
FROM lnbits/lnbits:latest
|
||||
FROM boltz/boltz-client:latest AS boltz
|
||||
FROM lnbits/lnbits:${LNBITS_TAG}
|
||||
|
||||
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
|
||||
RUN ls -l /usr/local/bin/boltzd
|
||||
|
||||
@@ -41,6 +41,10 @@ dev:
|
||||
docker:
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
test-frontend:
|
||||
npm install
|
||||
npm test
|
||||
|
||||
test-wallets:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
|
||||
[](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
# LNbits — The most powerful Bitcoin & Lightning toolkit
|
||||
|
||||
@@ -77,13 +78,11 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
@@ -4,6 +4,7 @@ color_scheme: dark
|
||||
logo: "/logos/lnbits-full-inverse.png"
|
||||
search_enabled: true
|
||||
url: https://docs.lnbits.org
|
||||
markdown: gfm
|
||||
aux_links:
|
||||
"LNbits on GitHub":
|
||||
- "//github.com/lnbits/lnbits"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: FastAPI extension upgrade
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
## Defining a route with path parameters
|
||||
|
||||
**old:**
|
||||
+34
-22
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Admin UI
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
@@ -12,11 +18,13 @@
|
||||
|
||||
# LNBits Admin UI
|
||||
|
||||
We introduced the Admin UI as the new default to make setup simpler and more straightforward. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
|
||||
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
|
||||
|
||||
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
|
||||
|
||||
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
|
||||
|
||||
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
|
||||
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
|
||||
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
|
||||
|
||||
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
|
||||
@@ -36,6 +44,27 @@ For a complete reference of legacy variables consult **[.env.example](../../.env
|
||||
> [!NOTE]
|
||||
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
|
||||
|
||||
## First run and Super User ID
|
||||
|
||||
On first start with the Admin UI enabled you will be prompted to generate a Super User.
|
||||
|
||||
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
|
||||
|
||||
If you need to read it from disk later:
|
||||
|
||||
```bash
|
||||
cat /lnbits/data/.super_user
|
||||
# example
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
|
||||
|
||||
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
|
||||
|
||||
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
|
||||
|
||||
## Enabling or disabling the Admin UI
|
||||
|
||||
The Admin UI is enabled by default on new installs. To change the state:
|
||||
@@ -76,23 +105,6 @@ The Admin UI is enabled by default on new installs. To change the state:
|
||||
|
||||
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
|
||||
|
||||
## First run and Super User ID
|
||||
|
||||
On first start with the Admin UI enabled you will be prompted to generate a Super User. If you need to read it from disk later:
|
||||
|
||||
```bash
|
||||
cat /lnbits/data/.super_user
|
||||
# example
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
|
||||
|
||||
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
|
||||
|
||||
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
|
||||
|
||||
## Allowed Users
|
||||
|
||||
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
|
||||
@@ -105,8 +117,8 @@ When set **at least one**, LNbits becomes private: only the listed users and Adm
|
||||
## Additional Guides
|
||||
|
||||
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
|
||||
- **[User Roles](./User_Roles.md)** — Overview of existing roles in LNbits.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What is available and how to configure each.
|
||||
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
@@ -115,7 +127,7 @@ LNbits empowers everyone with modular, open source tools for building Bitcoin ba
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Extension Install
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Extension Install
|
||||
|
||||
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Wallet comparison
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
@@ -72,7 +78,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
|
||||
+33
-27
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
@@ -51,8 +57,8 @@ LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most syst
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 2: UV (recommended for developers)
|
||||
@@ -117,8 +123,8 @@ uv run lnbits
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 2a (Legacy): Poetry — _Replaced by UV_
|
||||
@@ -197,8 +203,8 @@ poetry run lnbits
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
@@ -217,8 +223,8 @@ chmod +x lnbits.sh &&
|
||||
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
@@ -286,7 +292,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
||||
```
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> **Next steps**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
@@ -340,8 +346,8 @@ docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
@@ -380,14 +386,14 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
|
||||
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
|
||||
|
||||
> 
|
||||
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
|
||||
> Environment variable strings should be quoted here. For example, if `.env` has
|
||||
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
|
||||
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
|
||||
> Environment variable strings should be quoted here. For example, if `.env` has
|
||||
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
|
||||
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
|
||||
> 
|
||||
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
|
||||
> Example (LND REST funding source):
|
||||
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
|
||||
> Example (LND REST funding source):
|
||||
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
|
||||
|
||||
```
|
||||
@@ -445,8 +451,8 @@ poetry add setuptools wheel
|
||||
|
||||
> 
|
||||
>
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Troubleshooting
|
||||
@@ -524,22 +530,22 @@ Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
|
||||
> [!NOTE]
|
||||
> **Paths overview**
|
||||
>
|
||||
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
|
||||
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
|
||||
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
|
||||
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
|
||||
> - **Docker:** bind a host directory to `/app/data`.
|
||||
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
|
||||
> - **Docker:** bind a host directory to `/app/data`.
|
||||
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
|
||||
> The container reads `/app/.env` (usually bind-mounted from your project root).
|
||||
|
||||
> [!TIP]
|
||||
> **Local Lightning test network**
|
||||
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
|
||||
> **Local Lightning test network**
|
||||
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
|
||||
> https://lightningpolar.com/
|
||||
|
||||
> [!TIP]
|
||||
> **API comparison before updates**
|
||||
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
|
||||
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
|
||||
> **API comparison before updates**
|
||||
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
|
||||
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
|
||||
> https://arbadacarbayk.github.io/LNbits_TableTown/
|
||||
|
||||
# Additional guides
|
||||
@@ -884,5 +890,5 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
|
||||
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/) [](https://my.lnbits.com/login) [](https://news.lnbits.com/) [](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Super user
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
@@ -110,8 +116,8 @@ These are practical tips for running a safe and friendly instance.
|
||||
## Additional guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
|
||||
- **[User Roles](./User_Roles.md)** — Overview of roles and what they can do.
|
||||
- **[Funding sources](./funding-sources_table.md)** — Available options and how to enable and configure them.
|
||||
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
|
||||
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: User Roles
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
@@ -14,7 +20,7 @@
|
||||
|
||||
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
|
||||
|
||||
**Jump to:**
|
||||
**Jump to:**
|
||||
[Roles at a Glance](#roles-at-a-glance) •
|
||||
[Super User](#super-user--master-control) •
|
||||
[Admin](#admin--day-to-day-manager) •
|
||||
@@ -81,7 +87,7 @@ For using LNbits, not administering it.
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What’s available and how to enable/configure each.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
@@ -89,7 +95,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
|
||||
@@ -354,8 +354,8 @@ Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.s
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What’s available and how to enable/configure each.
|
||||
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
||||
};
|
||||
|
||||
checks = { };
|
||||
checks =
|
||||
import ./nix/tests { inherit pkgs; flake = self; };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .core.services import create_invoice, pay_invoice
|
||||
from .decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_super_user,
|
||||
check_user_exists,
|
||||
@@ -11,6 +12,7 @@ from .exceptions import InvoiceError, PaymentError
|
||||
__all__ = [
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_account_exists",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
@@ -44,6 +45,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
||||
|
||||
async def create_asset(
|
||||
entry: Asset,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("assets", entry)
|
||||
|
||||
|
||||
async def get_user_asset_info(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_asset_info(
|
||||
asset_id: str, conn: Connection | None = None
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_asset(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset_info(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def update_user_asset_info(
|
||||
asset: AssetInfo,
|
||||
) -> AssetInfo:
|
||||
await db.update("assets", asset)
|
||||
return asset
|
||||
|
||||
|
||||
async def delete_user_asset(
|
||||
user_id: str, asset_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets(
|
||||
user_id: str,
|
||||
filters: Filters[AssetFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AssetInfo]:
|
||||
filters = filters or Filters()
|
||||
filters.sortby = filters.sortby or "created_at"
|
||||
return await (conn or db).fetch_page(
|
||||
query="SELECT * FROM assets",
|
||||
where=["user_id = :user_id"],
|
||||
values={"user_id": user_id},
|
||||
filters=filters,
|
||||
model=AssetInfo,
|
||||
table_name="assets",
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets_count(user_id: str) -> int:
|
||||
result = await db.execute(
|
||||
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
|
||||
values={"user_id": user_id},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return row.get("count", 0)
|
||||
@@ -16,11 +16,12 @@ async def get_audit_entries(
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AuditEntry]:
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * from audit",
|
||||
"SELECT * FROM audit",
|
||||
[],
|
||||
{},
|
||||
filters=filters,
|
||||
model=AuditEntry,
|
||||
table_name="audit",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -33,13 +33,12 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str,
|
||||
conn: Connection | None = None,
|
||||
incoming: bool | None = False,
|
||||
wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment | None:
|
||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||
values = {
|
||||
"wallet_id": wallet_id,
|
||||
"checking_id": checking_id_or_hash,
|
||||
"hash": checking_id_or_hash,
|
||||
}
|
||||
@@ -47,6 +46,10 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
@@ -66,13 +69,16 @@ async def get_standalone_payment(
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet AND payment_hash = :hash
|
||||
""",
|
||||
{"wallet": wallet_id, "hash": payment_hash},
|
||||
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
return payment
|
||||
@@ -118,7 +124,6 @@ async def get_payments_paginated( # noqa: C901
|
||||
Filters payments to be returned by:
|
||||
- complete | pending | failed | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict[str, Any] = {
|
||||
"time": since,
|
||||
}
|
||||
@@ -128,7 +133,11 @@ async def get_payments_paginated( # noqa: C901
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return Page(data=[], total=0)
|
||||
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
@@ -171,6 +180,7 @@ async def get_payments_paginated( # noqa: C901
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
table_name="apipayments",
|
||||
)
|
||||
|
||||
|
||||
@@ -282,6 +292,7 @@ async def create_payment(
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -314,13 +325,14 @@ async def get_payments_history(
|
||||
wallet_id: str | None = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Filters | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
date_trunc = db.datetime_grouping(group)
|
||||
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
@@ -349,13 +361,13 @@ async def get_payments_history(
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return []
|
||||
balance = wallet.balance_msat
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
balance = await get_total_balance(conn=conn)
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
|
||||
+21
-11
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallets
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
@@ -30,9 +30,9 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def update_account(account: Account) -> Account:
|
||||
async def update_account(account: Account, conn: Connection | None = None) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("accounts", account)
|
||||
await (conn or db).update("accounts", account)
|
||||
return account
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ async def get_accounts(
|
||||
filters=filters,
|
||||
model=AccountOverview,
|
||||
group_by=["accounts.id"],
|
||||
table_name="accounts",
|
||||
)
|
||||
|
||||
|
||||
@@ -170,17 +171,24 @@ async def get_account_by_username_or_email(
|
||||
|
||||
|
||||
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
||||
account = await get_account(user_id, conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn)
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
account = await get_account(user_id, conn=conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn=conn)
|
||||
|
||||
|
||||
async def get_user_from_account(
|
||||
account: Account, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||
wallets = await get_wallets(account.id, False, conn=conn)
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
|
||||
wallets = await get_wallets(account.id, deleted=False, conn=conn)
|
||||
|
||||
if len(wallets) == 0:
|
||||
wallet = await create_wallet(user_id=account.id, conn=conn)
|
||||
wallets.append(wallet)
|
||||
|
||||
return User(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
@@ -199,9 +207,11 @@ async def get_user_from_account(
|
||||
)
|
||||
|
||||
|
||||
async def update_user_access_control_list(user_acls: UserAcls):
|
||||
async def update_user_access_control_list(
|
||||
user_acls: UserAcls, conn: Connection | None = None
|
||||
):
|
||||
user_acls.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("accounts", user_acls)
|
||||
await (conn or db).update("accounts", user_acls)
|
||||
|
||||
|
||||
async def get_user_access_control_lists(
|
||||
|
||||
+105
-13
@@ -3,9 +3,10 @@ from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..models import Wallet
|
||||
|
||||
@@ -14,17 +15,22 @@ async def create_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_name: str | None = None,
|
||||
wallet_type: WalletType = WalletType.LIGHTNING,
|
||||
shared_wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
wallet = Wallet(
|
||||
id=wallet_id,
|
||||
name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
wallet_type=wallet_type.value,
|
||||
shared_wallet_id=shared_wallet_id,
|
||||
user=user_id,
|
||||
adminkey=uuid4().hex,
|
||||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
@@ -46,6 +52,11 @@ async def delete_wallet(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
now = int(time())
|
||||
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
|
||||
if cached_wallet:
|
||||
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
|
||||
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
@@ -103,8 +114,8 @@ async def delete_unused_wallets(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
async def get_standalone_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
@@ -121,8 +132,23 @@ async def get_wallet(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
|
||||
if not wallet:
|
||||
return None
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
return await get_source_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
user_id: str,
|
||||
deleted: bool | None = False,
|
||||
wallet_type: WalletType | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[Wallet]:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
@@ -132,12 +158,20 @@ async def get_wallets(
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchall(
|
||||
if wallet_type is not None:
|
||||
query += " AND wallet_type = :wallet_type "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
{
|
||||
"user": user_id,
|
||||
"deleted": deleted,
|
||||
"wallet_type": wallet_type.value if wallet_type else None,
|
||||
},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
return await get_source_wallets(wallets, conn)
|
||||
|
||||
|
||||
async def get_wallets_paginated(
|
||||
user_id: str,
|
||||
@@ -149,7 +183,7 @@ async def get_wallets_paginated(
|
||||
deleted = False
|
||||
|
||||
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||
return await (conn or db).fetch_page(
|
||||
wallets = await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
@@ -159,20 +193,27 @@ async def get_wallets_paginated(
|
||||
values={"user": user_id, "deleted": deleted},
|
||||
filters=filters,
|
||||
model=Wallet,
|
||||
table_name="wallets",
|
||||
)
|
||||
|
||||
wallets.data = await get_source_wallets(wallets.data, conn)
|
||||
return wallets
|
||||
|
||||
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
user_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> list[str]:
|
||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||
query = """SELECT * FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += "AND deleted = :deleted"
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
query += " AND deleted = :deleted "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
wallets = await get_source_wallets(wallets, conn)
|
||||
return [w.source_wallet_id for w in wallets if w.can_view_payments]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
@@ -185,7 +226,7 @@ async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet | None:
|
||||
return await (conn or db).fetchone(
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
@@ -196,6 +237,57 @@ async def get_wallet_for_key(
|
||||
{"key": key},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
mw = await get_source_wallet(wallet, conn)
|
||||
return mw
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_base_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
) -> BaseWallet | None:
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
|
||||
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
|
||||
""",
|
||||
{"key": key},
|
||||
BaseWallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallet(
|
||||
wallet: Wallet, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
if not wallet.is_lightning_shared_wallet:
|
||||
return wallet
|
||||
if not wallet.shared_wallet_id:
|
||||
return None
|
||||
|
||||
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
|
||||
if not shared_wallet:
|
||||
return None
|
||||
wallet.mirror_shared_wallet(shared_wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallets(
|
||||
wallet: list[Wallet], conn: Connection | None = None
|
||||
) -> list[Wallet]:
|
||||
source_wallets = []
|
||||
for w in wallet:
|
||||
source_wallet = await get_source_wallet(w, conn)
|
||||
if source_wallet:
|
||||
source_wallets.append(source_wallet)
|
||||
return source_wallets
|
||||
|
||||
|
||||
async def get_total_balance(conn: Connection | None = None):
|
||||
|
||||
@@ -743,3 +743,108 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
name TEXT NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
thumbnail_base64 TEXT,
|
||||
thumbnail {db.blob},
|
||||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m039_index_payments(db: Connection):
|
||||
indexes = [
|
||||
"wallet_id",
|
||||
"checking_id",
|
||||
"payment_hash",
|
||||
"amount",
|
||||
"fee",
|
||||
"labels",
|
||||
"time",
|
||||
"status",
|
||||
"memo",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_payments_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m040_index_wallets(db: Connection):
|
||||
indexes = [
|
||||
"id",
|
||||
"user",
|
||||
"deleted",
|
||||
"adminkey",
|
||||
"inkey",
|
||||
"wallet_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m042_index_accounts(db: Connection):
|
||||
indexes = [
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ from .users import (
|
||||
UserAcls,
|
||||
UserExtra,
|
||||
)
|
||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
@@ -57,7 +57,6 @@ __all__ = [
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"CancelInvoice",
|
||||
"ConversionData",
|
||||
@@ -99,6 +98,7 @@ __all__ = [
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
"Wallet",
|
||||
"WalletInfo",
|
||||
"WalletTypeInfo",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
|
||||
|
||||
class AssetInfo(BaseModel):
|
||||
id: str
|
||||
mime_type: str
|
||||
name: str
|
||||
is_public: bool = False
|
||||
size_bytes: int
|
||||
thumbnail_base64: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Asset(AssetInfo):
|
||||
user_id: str
|
||||
data: bytes
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
class AssetFilters(FilterModel):
|
||||
__search_fields__ = ["name"]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"name",
|
||||
]
|
||||
|
||||
name: str | None = None
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -20,6 +21,7 @@ from lnbits.helpers import (
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
@@ -606,6 +608,37 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def get_installable_extensions(
|
||||
cls, post_refresh_cache: bool = False
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
|
||||
cache_key = "extensions:installable"
|
||||
cache_value = cache.value(cache_key)
|
||||
if not cache_value:
|
||||
extension_list = await cls._get_installable_extensions()
|
||||
cache.set(cache_key, extension_list, expiry=3600) # one hour
|
||||
return extension_list
|
||||
|
||||
if cache_value.older_than(10 * 60) or post_refresh_cache:
|
||||
# refresh cache in background if older than 10 minutes or requested
|
||||
create_task(cls._refresh_installable_extensions_cache())
|
||||
|
||||
extension_list = cache_value.value # type: ignore
|
||||
return extension_list
|
||||
|
||||
@classmethod
|
||||
async def _refresh_installable_extensions_cache(
|
||||
cls,
|
||||
) -> None:
|
||||
cache_key = "extensions:installable"
|
||||
extension_list: list[InstallableExtension] = (
|
||||
await cls._get_installable_extensions()
|
||||
)
|
||||
|
||||
cache.set(cache_key, extension_list, expiry=3600)
|
||||
|
||||
@classmethod
|
||||
async def _get_installable_extensions(
|
||||
cls,
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
|
||||
@@ -59,6 +59,7 @@ class CreatePayment(BaseModel):
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -81,6 +82,7 @@ class Payment(BaseModel):
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
|
||||
def __init__(self, **data):
|
||||
@@ -184,9 +186,25 @@ class Payment(BaseModel):
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
__search_fields__ = [
|
||||
"memo",
|
||||
"amount",
|
||||
"wallet_id",
|
||||
"tag",
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
]
|
||||
|
||||
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"amount",
|
||||
"fee",
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
@@ -198,6 +216,7 @@ class PaymentFilters(FilterModel):
|
||||
preimage: str | None
|
||||
payment_hash: str | None
|
||||
wallet_id: str | None
|
||||
labels: str | None
|
||||
|
||||
|
||||
class PaymentDataPoint(BaseModel):
|
||||
@@ -272,6 +291,7 @@ class CreateInvoice(BaseModel):
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
@@ -320,3 +340,7 @@ class CancelInvoice(BaseModel):
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
@@ -12,6 +12,7 @@ from lnbits.db import FilterModel
|
||||
from lnbits.helpers import (
|
||||
is_valid_email_address,
|
||||
is_valid_external_id,
|
||||
is_valid_label,
|
||||
is_valid_pubkey,
|
||||
is_valid_username,
|
||||
)
|
||||
@@ -29,6 +30,21 @@ class UserNotifications(BaseModel):
|
||||
incoming_payments_sats: int = 0
|
||||
|
||||
|
||||
class WalletInviteRequest(BaseModel):
|
||||
request_id: str
|
||||
from_user_name: str | None = None
|
||||
to_wallet_id: str
|
||||
to_wallet_name: str
|
||||
|
||||
|
||||
class UserLabel(BaseModel):
|
||||
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
|
||||
description: str | None = Field(default=None, max_length=250)
|
||||
color: str | None = Field(
|
||||
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
|
||||
) # e.g., "#RRGGBB"
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: bool | None = False
|
||||
first_name: str | None = None
|
||||
@@ -46,6 +62,55 @@ class UserExtra(BaseModel):
|
||||
|
||||
notifications: UserNotifications = UserNotifications()
|
||||
|
||||
wallet_invite_requests: list[WalletInviteRequest] = []
|
||||
|
||||
labels: list[UserLabel] = []
|
||||
|
||||
def add_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
to_wallet_id: str,
|
||||
to_wallet_name: str,
|
||||
from_user_name: str | None = None,
|
||||
) -> WalletInviteRequest:
|
||||
self.remove_wallet_invite_request(request_id)
|
||||
invite = WalletInviteRequest(
|
||||
request_id=request_id,
|
||||
from_user_name=from_user_name,
|
||||
to_wallet_id=to_wallet_id,
|
||||
to_wallet_name=to_wallet_name,
|
||||
)
|
||||
self.wallet_invite_requests.append(invite)
|
||||
return invite
|
||||
|
||||
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
|
||||
for invite in self.wallet_invite_requests:
|
||||
if invite.request_id == request_id:
|
||||
return invite
|
||||
return None
|
||||
|
||||
def validate_labels(self):
|
||||
seen_labels = set()
|
||||
for label in self.labels:
|
||||
if not label.name:
|
||||
raise ValueError("Label name cannot be empty.")
|
||||
# apply the same rule for labels as for usernames
|
||||
if not is_valid_label(label.name):
|
||||
raise ValueError(f"Invalid label name: {label.name}")
|
||||
if label.name in seen_labels:
|
||||
raise ValueError(f"Duplicate label name: {label.name}")
|
||||
seen_labels.add(label.name)
|
||||
|
||||
def remove_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
):
|
||||
self.wallet_invite_requests = [
|
||||
invite
|
||||
for invite in self.wallet_invite_requests
|
||||
if invite.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
@@ -107,8 +172,15 @@ class UserAcls(BaseModel):
|
||||
return None
|
||||
|
||||
|
||||
class Account(BaseModel):
|
||||
class AccountId(BaseModel):
|
||||
id: str
|
||||
|
||||
@property
|
||||
def is_admin_id(self) -> bool:
|
||||
return settings.is_admin_user(self.id)
|
||||
|
||||
|
||||
class Account(AccountId):
|
||||
external_id: str | None = None # for external account linking
|
||||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
@@ -129,6 +201,10 @@ class Account(BaseModel):
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
||||
|
||||
@property
|
||||
def has_password(self) -> bool:
|
||||
return self.password_hash is not None
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""sets and returns the hashed password"""
|
||||
salt = gensalt()
|
||||
@@ -160,6 +236,8 @@ class Account(BaseModel):
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
|
||||
self.extra.validate_labels()
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
transaction_count: int | None = 0
|
||||
@@ -170,7 +248,7 @@ class AccountOverview(Account):
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = [
|
||||
"user",
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
@@ -178,17 +256,18 @@ class AccountFilters(FilterModel):
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
email: str | None = None
|
||||
user: str | None = None
|
||||
id: str | None = None
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
|
||||
+172
-11
@@ -4,16 +4,14 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from lnurl import encode as lnurl_encode
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.lnurl import StoredPayLinks
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
class WalletInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
adminkey: str
|
||||
@@ -21,18 +19,109 @@ class BaseWallet(BaseModel):
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
LIGHTNING = "lightning"
|
||||
LIGHTNING_SHARED = "lightning-shared"
|
||||
|
||||
|
||||
class WalletPermission(Enum):
|
||||
VIEW_PAYMENTS = "view-payments"
|
||||
RECEIVE_PAYMENTS = "receive-payments"
|
||||
SEND_PAYMENTS = "send-payments"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class WalletShareStatus(Enum):
|
||||
INVITE_SENT = "invite_sent"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class WalletSharePermission(BaseModel):
|
||||
# unique identifier for this share request
|
||||
request_id: str | None = None
|
||||
# username of the invited user
|
||||
username: str
|
||||
# ID of the wallet being shared with
|
||||
shared_with_wallet_id: str | None = None
|
||||
# permissions being granted
|
||||
permissions: list[WalletPermission] = []
|
||||
# status of the share request
|
||||
status: WalletShareStatus
|
||||
comment: str | None = None
|
||||
|
||||
def approve(
|
||||
self,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
shared_with_wallet_id: str | None = None,
|
||||
):
|
||||
self.status = WalletShareStatus.APPROVED
|
||||
if permissions is not None:
|
||||
self.permissions = permissions
|
||||
if shared_with_wallet_id is not None:
|
||||
self.shared_with_wallet_id = shared_with_wallet_id
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
return self.status == WalletShareStatus.APPROVED
|
||||
|
||||
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
# What permissions this wallet grants when it's shared with other users
|
||||
shared_with: list[WalletSharePermission] = []
|
||||
|
||||
def invite_user_to_shared_wallet(
|
||||
self,
|
||||
request_id: str,
|
||||
request_type: WalletShareStatus,
|
||||
username: str,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
) -> WalletSharePermission:
|
||||
share = WalletSharePermission(
|
||||
request_id=request_id,
|
||||
username=username,
|
||||
status=request_type,
|
||||
permissions=permissions or [],
|
||||
)
|
||||
self.shared_with.append(share)
|
||||
return share
|
||||
|
||||
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.request_id == request_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def find_share_for_wallet(
|
||||
self, shared_with_wallet_id: str
|
||||
) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.shared_with_wallet_id == shared_with_wallet_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def remove_share_by_id(self, request_id: str):
|
||||
self.shared_with = [
|
||||
share for share in self.shared_with if share.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
name: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
adminkey: str
|
||||
inkey: str
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
name: str
|
||||
# Must be set only for shared wallets
|
||||
shared_wallet_id: str | None = None
|
||||
deleted: bool = False
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -40,6 +129,65 @@ class Wallet(BaseModel):
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
# What permission this wallet has when it's a shared wallet
|
||||
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self._validate_data()
|
||||
|
||||
def mirror_shared_wallet(
|
||||
self,
|
||||
shared_wallet: Wallet,
|
||||
):
|
||||
if not shared_wallet.is_lightning_wallet:
|
||||
return None
|
||||
|
||||
self.wallet_type = WalletType.LIGHTNING_SHARED.value
|
||||
self.shared_wallet_id = shared_wallet.id
|
||||
self.name = shared_wallet.name
|
||||
self.share_permissions = shared_wallet.get_share_permissions(self.id)
|
||||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
self.extra.icon = shared_wallet.extra.icon
|
||||
self.extra.color = shared_wallet.extra.color
|
||||
|
||||
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
|
||||
for share in self.extra.shared_with:
|
||||
if share.shared_with_wallet_id == wallet_id and share.is_approved:
|
||||
return share.permissions
|
||||
return []
|
||||
|
||||
def has_permission(self, permission: WalletPermission) -> bool:
|
||||
if self.is_lightning_wallet:
|
||||
return True
|
||||
if self.is_lightning_shared_wallet:
|
||||
return permission in self.share_permissions
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def source_wallet_id(self) -> str:
|
||||
"""For shared wallets return the original wallet ID, else return own ID."""
|
||||
if self.is_lightning_shared_wallet and len(self.share_permissions):
|
||||
return self.shared_wallet_id or self.id
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def can_receive_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_send_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.SEND_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_view_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -50,16 +198,23 @@ class Wallet(BaseModel):
|
||||
return self.balance_msat - settings.fee_reserve(self.balance_msat)
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except Exception:
|
||||
return ""
|
||||
def is_lightning_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING.value
|
||||
|
||||
@property
|
||||
def is_lightning_shared_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
|
||||
|
||||
def _validate_data(self):
|
||||
if self.is_lightning_shared_wallet:
|
||||
if not self.shared_wallet_id:
|
||||
raise ValueError("Shared wallet ID must be set for shared wallets.")
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: str | None = None
|
||||
wallet_type: WalletType = WalletType.LIGHTNING
|
||||
shared_wallet_id: str | None = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
@@ -78,6 +233,12 @@ class WalletTypeInfo:
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseWalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: BaseWallet
|
||||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import base64
|
||||
import io
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import UploadFile
|
||||
from PIL import Image
|
||||
|
||||
from lnbits.core.crud.assets import create_asset, get_user_assets_count
|
||||
from lnbits.core.models.assets import Asset
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
|
||||
if not file.content_type:
|
||||
raise ValueError("File must have a content type.")
|
||||
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
|
||||
raise ValueError(f"File type '{file.content_type}' not allowed.")
|
||||
|
||||
if not settings.is_unlimited_assets_user(user_id):
|
||||
user_assets_count = await get_user_assets_count(user_id)
|
||||
if user_assets_count >= settings.lnbits_max_assets_per_user:
|
||||
raise ValueError(
|
||||
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
|
||||
raise ValueError(
|
||||
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
|
||||
)
|
||||
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
|
||||
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
|
||||
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
|
||||
image.thumbnail((thumbnail_width, thumbnail_height))
|
||||
|
||||
# Save thumbnail to an in-memory buffer
|
||||
thumb_buffer = io.BytesIO()
|
||||
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
|
||||
image.save(thumb_buffer, format=thumbnail_format)
|
||||
thumb_buffer.seek(0)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
mime_type=file.content_type,
|
||||
is_public=is_public,
|
||||
name=file.filename or "unnamed",
|
||||
size_bytes=len(contents),
|
||||
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
|
||||
data=contents,
|
||||
)
|
||||
|
||||
await create_asset(asset)
|
||||
return asset
|
||||
@@ -16,6 +16,7 @@ from lnbits.core.crud.extensions import (
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
@@ -149,9 +150,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
|
||||
|
||||
async def get_valid_extensions(
|
||||
include_deactivated: bool | None = True,
|
||||
include_deactivated: bool | None = True, conn: Connection | None = None
|
||||
) -> list[Extension]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
installed_extensions = await get_installed_extensions(conn=conn)
|
||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||
|
||||
if include_deactivated:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
@@ -70,6 +72,63 @@ def check_stripe_signature(
|
||||
raise ValueError("Stripe signature verification failed.")
|
||||
|
||||
|
||||
async def verify_paypal_webhook(headers, payload: bytes):
|
||||
"""
|
||||
Validate PayPal webhook signatures using the PayPal verify API.
|
||||
"""
|
||||
webhook_id = settings.paypal_webhook_id
|
||||
if not webhook_id:
|
||||
logger.warning("PayPal webhook ID not set; skipping verification.")
|
||||
return
|
||||
|
||||
required_headers = {
|
||||
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
|
||||
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
|
||||
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
|
||||
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
|
||||
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
|
||||
}
|
||||
if not all(required_headers.values()):
|
||||
logger.warning("Missing PayPal webhook headers; skipping verification.")
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
|
||||
token_resp = await client.post(
|
||||
"/v1/oauth2/token",
|
||||
data={"grant_type": "client_credentials"},
|
||||
auth=(
|
||||
settings.paypal_client_id or "",
|
||||
settings.paypal_client_secret or "",
|
||||
),
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json().get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError("PayPal token missing in verification flow.")
|
||||
|
||||
verify_resp = await client.post(
|
||||
"/v1/notifications/verify-webhook-signature",
|
||||
json={
|
||||
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
|
||||
"cert_url": required_headers["PAYPAL-CERT-URL"],
|
||||
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
|
||||
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
|
||||
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
|
||||
"webhook_id": webhook_id,
|
||||
"webhook_event": json.loads(payload.decode()),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
verify_resp.raise_for_status()
|
||||
verification_status = verify_resp.json().get("verification_status")
|
||||
if verification_status != "SUCCESS":
|
||||
raise ValueError("PayPal webhook verification failed.")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise ValueError("PayPal webhook cannot be verified.") from exc
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
|
||||
@@ -70,7 +70,8 @@ async def check_balance_delta_changed():
|
||||
if settings.latest_balance_delta_sats is None:
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
return
|
||||
if status.delta_sats != settings.latest_balance_delta_sats:
|
||||
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats)
|
||||
if delta_change >= settings.notification_balance_delta_threshold_sats:
|
||||
enqueue_admin_notification(
|
||||
NotificationType.balance_delta,
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import smtplib
|
||||
from asyncio.tasks import create_task
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
@@ -16,6 +17,7 @@ from lnbits.core.crud import (
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
@@ -240,6 +242,10 @@ async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(payment.webhook)
|
||||
except ValueError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||
@@ -257,6 +263,12 @@ async def dispatch_webhook(payment: Payment):
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
for shared in wallet.extra.shared_with:
|
||||
if not shared.shared_with_wallet_id:
|
||||
continue
|
||||
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
|
||||
if shared_wallet and shared_wallet.can_view_payments:
|
||||
await send_ws_payment_notification(shared_wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
@@ -275,6 +287,13 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
logger.error(f"Error dispatching webhook: {e!s}")
|
||||
|
||||
|
||||
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
create_task(send_payment_notification(wallet, payment))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
@@ -381,7 +400,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_changed
|
||||
return settings.notification_balance_delta_threshold_sats > 0
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
|
||||
@@ -47,7 +47,7 @@ from ..models import (
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .notifications import send_payment_notification
|
||||
from .notifications import send_payment_notification_in_background
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||
@@ -61,11 +61,13 @@ async def pay_invoice(
|
||||
extra: dict | None = None,
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
labels: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
@@ -73,25 +75,34 @@ async def pay_invoice(
|
||||
amount_msat = invoice.amount_msat
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
|
||||
|
||||
if not wallet.can_send_payments:
|
||||
raise PaymentError(
|
||||
"Wallet does not have permission to pay invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, new_conn):
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=-amount_msat,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
||||
payment = await _pay_invoice(
|
||||
wallet.source_wallet_id, create_payment_model, conn=new_conn
|
||||
)
|
||||
|
||||
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -190,19 +201,22 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
)
|
||||
async with db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if data.lnurl_withdraw:
|
||||
try:
|
||||
@@ -241,6 +255,7 @@ async def create_invoice(
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -250,6 +265,12 @@ async def create_invoice(
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
if not user_wallet.can_receive_payments:
|
||||
raise InvoiceError(
|
||||
"Wallet does not have permission to create invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
invoice_memo = None if description_hash else memo[:640]
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
@@ -308,7 +329,7 @@ async def create_invoice(
|
||||
invoice = bolt11_decode(invoice_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=user_wallet.source_wallet_id,
|
||||
bolt11=invoice_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=invoice_response.preimage,
|
||||
@@ -318,6 +339,7 @@ async def create_invoice(
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -339,13 +361,15 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -456,7 +480,7 @@ async def update_wallet_balance(
|
||||
await create_payment(
|
||||
checking_id=f"internal_{payment_hash}",
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=bolt11,
|
||||
payment_hash=payment_hash,
|
||||
amount_msat=amount * 1000,
|
||||
@@ -475,7 +499,7 @@ async def update_wallet_balance(
|
||||
raise ValueError("Balance change failed, amount exceeds maximum balance.")
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
amount=amount,
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
@@ -682,6 +706,7 @@ async def _pay_internal_invoice(
|
||||
internal_payment = await check_internal(
|
||||
create_payment_model.payment_hash, conn=conn
|
||||
)
|
||||
|
||||
if not internal_payment:
|
||||
return None
|
||||
|
||||
@@ -690,6 +715,7 @@ async def _pay_internal_invoice(
|
||||
internal_invoice = await get_standalone_payment(
|
||||
internal_payment.checking_id, incoming=True, conn=conn
|
||||
)
|
||||
|
||||
if not internal_invoice:
|
||||
raise PaymentError("Internal payment not found.", status="failed")
|
||||
|
||||
@@ -711,6 +737,7 @@ async def _pay_internal_invoice(
|
||||
|
||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
data=create_payment_model,
|
||||
@@ -725,7 +752,7 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await send_payment_notification(wallet, payment)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -798,7 +825,7 @@ async def _pay_external_invoice(
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
@@ -910,7 +937,7 @@ async def _credit_service_fee_wallet(
|
||||
|
||||
memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
|
||||
@@ -3,7 +3,9 @@ from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import UserExtension
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
@@ -48,37 +50,43 @@ async def create_user_account_no_ckeck(
|
||||
account: Account | None = None,
|
||||
wallet_name: str | None = None,
|
||||
default_exts: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(
|
||||
account.username, conn=conn
|
||||
):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(account.username):
|
||||
raise ValueError("Username already exists.")
|
||||
if account.email and await get_account_by_email(account.email, conn=conn):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if account.email and await get_account_by_email(account.email):
|
||||
raise ValueError("Email already exists.")
|
||||
if account.pubkey and await get_account_by_pubkey(
|
||||
account.pubkey, conn=conn
|
||||
):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
|
||||
if account.pubkey and await get_account_by_pubkey(account.pubkey):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
account = await create_account(account, conn=conn)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
account = await create_account(account)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
)
|
||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
||||
for ext_id in user_extensions:
|
||||
try:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await create_user_extension(user_ext, conn=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
||||
for ext_id in user_extensions:
|
||||
try:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await create_user_extension(user_ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
user = await get_user_from_account(account, conn=conn)
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from lnbits.core.crud.users import (
|
||||
get_account,
|
||||
get_account_by_username_or_email,
|
||||
update_account,
|
||||
)
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
force_delete_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.models.wallets import (
|
||||
Wallet,
|
||||
WalletSharePermission,
|
||||
WalletShareStatus,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
async def invite_to_wallet(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.username:
|
||||
raise ValueError("Username or email missing.")
|
||||
invited_user = await get_account_by_username_or_email(data.username)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if share:
|
||||
raise ValueError("User already invited to this wallet.")
|
||||
|
||||
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
|
||||
request_id=request_id,
|
||||
request_type=WalletShareStatus.INVITE_SENT,
|
||||
username=data.username,
|
||||
permissions=data.permissions,
|
||||
)
|
||||
await update_wallet(source_wallet)
|
||||
|
||||
wallet_owner = await get_account(source_wallet.user)
|
||||
if not wallet_owner:
|
||||
raise ValueError("Cannot find wallet owner.")
|
||||
invited_user.extra.add_wallet_invite_request(
|
||||
request_id=request_id,
|
||||
from_user_name=wallet_owner.username or wallet_owner.email,
|
||||
to_wallet_id=source_wallet.id,
|
||||
to_wallet_name=source_wallet.name,
|
||||
)
|
||||
await update_account(invited_user)
|
||||
|
||||
return invite_request
|
||||
|
||||
|
||||
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
|
||||
invited_user = await get_account(invited_user_id)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("Invitation not found.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(share_request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
|
||||
async def update_wallet_share_permissions(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.shared_with_wallet_id:
|
||||
raise ValueError("Wallet ID missing.")
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
|
||||
if not share:
|
||||
raise ValueError("Share not found")
|
||||
|
||||
if not share.shared_with_wallet_id:
|
||||
raise ValueError("Share does not have a mirror wallet ID.")
|
||||
|
||||
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
|
||||
if not mirror_wallet:
|
||||
raise ValueError("Target wallet not found")
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared wallet.")
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
share.approve(permissions=data.permissions)
|
||||
await update_wallet(source_wallet)
|
||||
return share
|
||||
|
||||
|
||||
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Source wallet is not a lightning wallet.")
|
||||
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not share:
|
||||
raise ValueError("Wallet share not found.")
|
||||
source_wallet.extra.remove_share_by_id(request_id)
|
||||
|
||||
invited_user = await get_account_by_username_or_email(share.username)
|
||||
if not invited_user:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Invited user not found."
|
||||
)
|
||||
if invited_user.extra.find_wallet_invite_request(request_id):
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
mirror_wallets = await get_wallets(
|
||||
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
|
||||
)
|
||||
mirror_wallet = next(
|
||||
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
|
||||
)
|
||||
|
||||
if not mirror_wallet:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Target wallet not found."
|
||||
)
|
||||
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared lightning wallet.")
|
||||
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
await force_delete_wallet(mirror_wallet.id)
|
||||
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(success=True, message="Permission removed.")
|
||||
|
||||
|
||||
async def create_lightning_shared_wallet(
|
||||
user_id: str,
|
||||
source_wallet_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
|
||||
if not source_wallet:
|
||||
raise ValueError("Shared wallet does not exist.")
|
||||
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Shared wallet is not a lightning wallet.")
|
||||
|
||||
if source_wallet.user == user_id:
|
||||
raise ValueError("Cannot mirror your own wallet.")
|
||||
|
||||
invited_user = await get_account(user_id, conn=conn)
|
||||
if not invited_user:
|
||||
raise ValueError("Cannot find invited user.")
|
||||
|
||||
return await _accept_invitation_to_shared_wallet(
|
||||
invited_user, source_wallet, conn=conn
|
||||
)
|
||||
|
||||
|
||||
async def _accept_invitation_to_shared_wallet(
|
||||
invited_user: Account,
|
||||
source_wallet: Wallet,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
existing_request = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("No invitation found for this invited user.")
|
||||
if existing_request.status == WalletShareStatus.APPROVED:
|
||||
raise ValueError("This wallet is already shared with you.")
|
||||
if existing_request.status != WalletShareStatus.INVITE_SENT:
|
||||
raise ValueError("Unknown request type.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
# todo: double check if user already has a mirror wallet for this source wallet
|
||||
|
||||
mirror_wallet = await create_wallet(
|
||||
user_id=invited_user.id,
|
||||
wallet_name=source_wallet.name,
|
||||
wallet_type=WalletType.LIGHTNING_SHARED,
|
||||
shared_wallet_id=source_wallet.id,
|
||||
conn=conn,
|
||||
)
|
||||
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
|
||||
await update_wallet(source_wallet, conn=conn)
|
||||
mirror_wallet.mirror_shared_wallet(source_wallet)
|
||||
return mirror_wallet
|
||||
@@ -35,7 +35,7 @@ async def run_by_the_minute_tasks() -> None:
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
if settings.notification_balance_delta_changed:
|
||||
if settings.notification_balance_delta_threshold_sats > 0:
|
||||
try:
|
||||
# runs by default every minute, the delta should not change that often
|
||||
await check_balance_delta_changed()
|
||||
@@ -57,7 +57,9 @@ async def run_by_the_minute_tasks() -> None:
|
||||
if minute_counter % 60 == 0:
|
||||
try:
|
||||
# initialize the list of all extensions
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=True
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<q-tab-panel name="exchange_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div class="q-pa-sm">
|
||||
<canvas
|
||||
style="
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 350px;
|
||||
max-height: 50vh;
|
||||
"
|
||||
ref="exchangeRatesChart"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
|
||||
type="number"
|
||||
label="Refresh Interval (seconds)"
|
||||
hint="How often should the exchange rates be fetched. Set to zero to disable."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_size"
|
||||
type="number"
|
||||
label="History Size"
|
||||
hint="How many data points should be kept in memory."
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Refresh Interval</code> and <code> History Size </code>are for
|
||||
historical purposes only.
|
||||
</li>
|
||||
<li>These two settings do not affect the live price computation.</li>
|
||||
<li>
|
||||
Chart currency:
|
||||
<strong
|
||||
><span
|
||||
v-text="formData.lnbits_default_accounting_currency || 'USD'"
|
||||
></span
|
||||
></strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="addExchangeProvider()"
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
|
||||
flat
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
:columns="exchangesTable.columns"
|
||||
v-model:pagination="exchangesTable.pagination"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="removeExchangeProvider(props.row)"
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.name"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td full-width>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.api_url"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input
|
||||
></q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.path"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="props.row.exclude_to"
|
||||
@update:model-value="touchSettings()"
|
||||
multiple
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showTickerConversionDialog(props.row)"
|
||||
round
|
||||
icon="add"
|
||||
size="sm"
|
||||
color="gray"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
<q-chip
|
||||
v-for="ticker, index in props.row.ticker_conversion"
|
||||
:key="ticker"
|
||||
removable
|
||||
dense
|
||||
filled
|
||||
@remove="removeExchangeTickerConversion(props.row, ticker)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="ticker"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<ul>
|
||||
<li>
|
||||
<code>API URL</code> and <code>JSON Path</code> fields can use the
|
||||
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
|
||||
currency
|
||||
</li>
|
||||
<li>
|
||||
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
|
||||
lowercase code
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-separator class="q-ma-md"></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_rate_cache_seconds"
|
||||
type="number"
|
||||
label="Exchange rate cache (seconds)"
|
||||
hint="For how many seconds should the exchange rate be cached."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12"></div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,287 +0,0 @@
|
||||
<q-tab-panel name="fiat_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('fiat_providers')"></span>
|
||||
<q-btn
|
||||
round
|
||||
flat
|
||||
@click="hideInputsToggle()"
|
||||
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
|
||||
></q-btn>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-list bordered class="rounded-borders">
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Stripe </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">
|
||||
<q-toggle
|
||||
size="md"
|
||||
:label="$t('enabled')"
|
||||
v-model="formData.stripe_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.stripe_api_endpoint"
|
||||
:label="$t('endpoint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_api_secret_key"
|
||||
:label="$t('secret_key')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.stripe_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('stripe')"
|
||||
></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_stripe_description')"></span>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
disable
|
||||
v-model="formData.stripe_payment_webhook_url"
|
||||
:label="$t('webhook_url')"
|
||||
:hint="$t('webhook_url_hint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_webhook_signing_secret"
|
||||
:label="$t('signing_secret')"
|
||||
:hint="$t('signing_secret_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_events_list')"></span>
|
||||
<ul>
|
||||
<li><code>checkout.session.completed</code></li>
|
||||
- the user completed the checkout process
|
||||
<li><code>invoice.paid</code></li>
|
||||
- the invoice was successfully paid (for subscriptions)
|
||||
</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.stripe_limits.service_fee_percent"
|
||||
@update:model-value="touchSettings()"
|
||||
: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.stripe_limits.service_max_fee_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
: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.stripe_limits.service_fee_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
: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.stripe_limits.service_min_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
: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.stripe_limits.service_max_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
: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.stripe_limits.service_faucet_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
: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: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_3')"></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
|
||||
></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="formAddStripeUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
|
||||
>
|
||||
<q-btn
|
||||
@click="addStripeAllowedUser"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.stripe_limits.allowed_users"
|
||||
@update:model-value="touchSettings()"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeStripeAllowedUser(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-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Square </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">Disabled</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card>
|
||||
<q-card-section> Coming Soon </q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,67 +0,0 @@
|
||||
<q-tab-panel name="library">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('image_library')"></span>
|
||||
</h6>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Add Image"
|
||||
@click="$refs.imageInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="imageInput"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
style="display: none"
|
||||
@change="onImageInput"
|
||||
/>
|
||||
</q-card-section>
|
||||
<div class="row q-col-gutter-sm q-pa-sm">
|
||||
<div
|
||||
v-for="image in library_images"
|
||||
:key="image.filename"
|
||||
class="col-6 col-sm-4 col-md-3 col-lg-2"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<q-card class="q-mb-sm">
|
||||
<q-img :src="image.url" style="height: 150px" />
|
||||
|
||||
<q-card-section
|
||||
class="q-pt-md q-pb-md row items-center justify-between"
|
||||
>
|
||||
<small
|
||||
><div
|
||||
class="text-caption ellipsis"
|
||||
style="max-width: 100px"
|
||||
:title="image.filename"
|
||||
v-text="image.filename"
|
||||
></div
|
||||
></small>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
@click="copyText(image.url)"
|
||||
:title="$t('copy')"
|
||||
><q-tooltip>Copy image link</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="deleteImage(image.filename)"
|
||||
:title="$t('delete')"
|
||||
><q-tooltip>Delete image</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="library_images.length === 0" class="q-pa-xl">
|
||||
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,251 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
|
||||
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
:label="$t('save')"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
:label="$t('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
class="q-ml-md"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
:label="$t('download_backup')"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs
|
||||
@update:model-value="showExchangeProvidersTab"
|
||||
v-model="tab"
|
||||
vertical
|
||||
active-color="primary"
|
||||
>
|
||||
<q-tab
|
||||
name="funding"
|
||||
icon="account_balance_wallet"
|
||||
:label="$q.screen.gt.sm ? $t('funding') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('funding')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="security"
|
||||
icon="security"
|
||||
:label="$q.screen.gt.sm ? $t('security') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('security')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
icon="price_change"
|
||||
:label="$q.screen.gt.sm ? $t('payments') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('payments')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="exchange_providers"
|
||||
icon="show_chart"
|
||||
:label="$q.screen.gt.sm ? $t('exchanges') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="fiat_providers"
|
||||
icon="credit_score"
|
||||
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('fiat_providers')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('users')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="extensions"
|
||||
icon="extension"
|
||||
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('extensions')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="notifications"
|
||||
icon="notifications"
|
||||
:label="$q.screen.gt.sm ? $t('notifications') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('notifications')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="audit"
|
||||
icon="playlist_add_check_circle"
|
||||
:label="$q.screen.gt.sm ? $t('audit') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('audit')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="library"
|
||||
icon="image"
|
||||
:label="$q.screen.gt.sm ? $t('library') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('library')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
style="word-break: break-all"
|
||||
name="site_customisation"
|
||||
icon="language"
|
||||
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('site_customisation')"></span></q-tooltip
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-scroll-area style="height: 100vh">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
scroll
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
||||
"admin/_tab_fiat_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-splitter>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="q-mb-md">
|
||||
<strong v-text="$t('create_ticker_converter')"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 q-mb-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="exchangeData.convertFromTicker"
|
||||
label="From Currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="exchangeData.convertToTicker"
|
||||
dense
|
||||
filled
|
||||
label="New Ticker"
|
||||
hint="This ticker will be used for the exchange API calls."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="addExchangeTickerConversion()"
|
||||
label="Add Ticker Conversion"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
@@ -1,201 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<!-- Optional: Add content here if needed -->
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#audit"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center q-mb-lg">
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('components')"></strong>
|
||||
<div style="width: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('long_running_endpoints')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_request_methods')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_response_codes')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="id"
|
||||
:rows="auditEntries"
|
||||
:columns="auditTable.columns"
|
||||
v-model:pagination="auditTable.pagination"
|
||||
:filter="auditTable.search"
|
||||
:loading="auditTable.loading"
|
||||
@request="fetchAudit"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchAuditBy()"
|
||||
@update:model-value="searchAuditBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchAuditBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchAuditBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div v-if="col.name == 'created_at'">
|
||||
<q-btn
|
||||
icon="description"
|
||||
:disable="!props.row.request_details"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="showDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('request_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
<q-tooltip v-if="props.row.delete_at">
|
||||
<span
|
||||
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['user_id', 'request_details'].includes(col.name)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="props.row[col.name]"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row[col.name])"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name])"> </span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
@click="searchAuditBy(col.name, props.row[col.name])"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="auditDetailsDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong v-text="$t('http_request_details')"></strong>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="auditDetailsDialog.data"
|
||||
type="textarea"
|
||||
rows="25"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="copyText(auditDetailsDialog.data)"
|
||||
icon="copy_content"
|
||||
color="grey"
|
||||
flat
|
||||
v-text="$t('copy')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,305 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer"
|
||||
@click="copyText(wallet.id)"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Admin key: </strong
|
||||
><em
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.adminkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.adminkey"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Invoice/read key: </strong
|
||||
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="inkeyHidden = !inkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.inkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.inkey"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "name": <string>, "balance":
|
||||
<int>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
|
||||
>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create an invoice (incoming)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>,
|
||||
"expiry": <int>, "unit": <string>, "webhook":
|
||||
<url:string>, "internal": <bool>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"payment_hash": <string>, "payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Pay an invoice (outgoing)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span> /api/v1/payments (reveal
|
||||
admin keys
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon
|
||||
>)</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"out": true, "bolt11": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": true, "bolt11": <string>}' -H "X-Api-Key:
|
||||
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
|
||||
>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Decode an invoice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"data": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check an invoice (incoming or outgoing)"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/payments/<payment_hash></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"paid": <bool>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET
|
||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">WS</span>
|
||||
/api/v1/ws/<invoice_key></code
|
||||
>
|
||||
<h5
|
||||
class="text-caption q-mt-sm q-mb-none"
|
||||
v-text="$t('websocket_example')"
|
||||
></h5>
|
||||
<code
|
||||
>wscat -c <span v-text="websocketUrl"></span>/<span
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></span
|
||||
></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)/payments
|
||||
</h5>
|
||||
<code>{"balance": <int>, "payment": <object>}</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<p v-text="$t('reset_wallet_keys_desc')"></p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="resetKeys()"
|
||||
:label="$t('reset_wallet_keys')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,830 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-stepper
|
||||
v-model="step"
|
||||
ref="stepper"
|
||||
color="primary"
|
||||
animated
|
||||
header-nav
|
||||
class="q-pt-sm"
|
||||
@update:model-value="onStepChange"
|
||||
>
|
||||
<q-step
|
||||
:name="1"
|
||||
title="Describe"
|
||||
icon="info"
|
||||
:done="step > 1"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<span class="text-h6">
|
||||
Tell us something about your extension:
|
||||
</span>
|
||||
<ul>
|
||||
<li>This is the first step, you can return and change it.</li>
|
||||
<li>
|
||||
The <code>`name`</code> and
|
||||
<code>`sort description`</code> fields are what the users will
|
||||
see when browsing the list of extensions.
|
||||
</li>
|
||||
<li>
|
||||
The <code>`id`</code> field is used internally and in the URL of
|
||||
your extension.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- todo: add icon -->
|
||||
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Upload Existing config"
|
||||
@click="$refs.extensionDataInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="extensionDataInput"
|
||||
accept="application/json"
|
||||
style="display: none"
|
||||
@change="onJsonDataInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mt-sm"></q-separator>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.name"
|
||||
label="Extension Name"
|
||||
hint="The name of your extension"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.id"
|
||||
label="Extension Id"
|
||||
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.short_description"
|
||||
label="Short Description"
|
||||
hint="A short description that is shown in the extension list."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.description"
|
||||
label="Description"
|
||||
hint="A detailed description of your extension."
|
||||
type="textarea"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="2"
|
||||
title="Settings"
|
||||
icon="settings"
|
||||
:done="step > 2"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep2"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('settings')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-toggle
|
||||
v-model="extensionData.settings_data.enabled"
|
||||
label="Generate Settings Fields"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
<br />
|
||||
|
||||
<ul>
|
||||
<li>Define what settings your extension will have.</li>
|
||||
<li>
|
||||
You can choose if each user has its own settings or if the
|
||||
settings are global (set by the admin).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator
|
||||
v-if="extensionData.settings_data.enabled"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.settings_data.type"
|
||||
:options="settingsTypes"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-md-10 col-sm-12 q-pt-sm">
|
||||
<q-badge
|
||||
v-if="extensionData.settings_data.type === 'user'"
|
||||
outline
|
||||
class="text-caption q-ml-md"
|
||||
>Each user can set its own settings for this extension.</q-badge
|
||||
>
|
||||
<q-badge v-else outline class="text-caption q-ml-md"
|
||||
>Settings are set by the admin and apply to all users of the
|
||||
extension</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.settings_data.fields"
|
||||
:hide-advanced="true"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="3"
|
||||
:done="step > 3"
|
||||
title="Owner Data"
|
||||
icon="list"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep3"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('owner_data')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-input
|
||||
v-model="extensionData.owner_data.name"
|
||||
filled
|
||||
label="Owner Table Name"
|
||||
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
|
||||
class="q-mb-xl"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
The owner of the extension manages this data. It can add, remove
|
||||
and update instances of it.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Some fileds are present by default, like
|
||||
<code>created_at</code>, <code>updated_at</code> and
|
||||
<code>extra</code>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.owner_data.fields"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="4"
|
||||
:done="step > 4"
|
||||
title="Client Data"
|
||||
icon="blur_linear"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep4"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('client_data')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<!-- <q-toggle
|
||||
v-model="extensionData.client_data.enabled"
|
||||
label="Generate Client Table"
|
||||
disable
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
|
||||
<br /> -->
|
||||
<q-input
|
||||
v-if="extensionData.client_data.enabled"
|
||||
v-model="extensionData.client_data.name"
|
||||
filled
|
||||
label="Client Table Name"
|
||||
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
|
||||
class="q-mb-xl"
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
This data is created by users of the extension. Usually when
|
||||
they submit a form or make a payment.
|
||||
</li>
|
||||
<li>
|
||||
The owner of the extension can view this data, but should not
|
||||
modify it.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator
|
||||
v-if="extensionData.client_data.enabled"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.client_data.fields"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="5"
|
||||
:done="step > 5"
|
||||
title="Public Pages"
|
||||
icon="link"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep5"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('public_page')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.has_public_page"
|
||||
label="Generate Public Page"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
<br />
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Most extensions have a public page that can be shared (this page
|
||||
will still be accessible even if you have restricted access to
|
||||
your LNbits install).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="extensionData.public_page.has_public_page">
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page title</q-item-label>
|
||||
<q-item-label caption
|
||||
>Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code>
|
||||
(Owner Data) that will be used as a title for the public
|
||||
page.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.owner_data_fields.name"
|
||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page description</q-item-label>
|
||||
<q-item-label caption
|
||||
>Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code>
|
||||
(Owner Data) that will be used as a description for the
|
||||
public page.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.owner_data_fields.description"
|
||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page inputs</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the fields from the
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that will be shown as inputs in
|
||||
the public page form.
|
||||
</li>
|
||||
<li>You can select multiple fields.</li>
|
||||
<li>
|
||||
A corresponding input field will be created for each
|
||||
selected field.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
v-model="extensionData.public_page.client_data_fields.public_inputs"
|
||||
:options="extensionData.client_data.fields.map(f => f.name)"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Generate Action Button</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
If enabled, the public page will have a button to
|
||||
perform an action (e.g. generate a payment request).
|
||||
</li>
|
||||
<li>
|
||||
The action will use the selected input fields from
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) as parameters.
|
||||
</li>
|
||||
<li>
|
||||
A corresponding REST API endpoint will be created.
|
||||
</li>
|
||||
</ul></q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.action_fields.generate_action"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator
|
||||
v-if="extensionData.public_page.action_fields.generate_action"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
|
||||
<div v-if="extensionData.public_page.action_fields.generate_action">
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Generate Payment Logic</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
If enabled, the endpoint will create an invoice from
|
||||
the submitted data and the UI will show the QR code
|
||||
with the invoice.
|
||||
</li>
|
||||
<li>
|
||||
A listener will be created to check for the pay event.
|
||||
</li>
|
||||
<li>You must map the fieds.</li>
|
||||
</ul></q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.action_fields.generate_payment_logic"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
|
||||
class="row q-col-gutter-md q-mt-md"
|
||||
>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Wallet</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) that represents the wallet which
|
||||
will generate the invoice and receive the payments.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Only fields with the type <code>Wallet</code> will be
|
||||
shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.wallet_id"
|
||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Currency</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) that represents the currency
|
||||
which will be used to for the amount.
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Currency</code> will
|
||||
be shown.
|
||||
</li>
|
||||
<li>Empty if you want to use sats.</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.currency"
|
||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Amount</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) or
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that represents the amount (in
|
||||
the selected currency).
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Integer</code> and
|
||||
<code>Float</code> will be shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.amount_source"
|
||||
:options="amountSource"
|
||||
class="q-mr-sm"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.amount"
|
||||
:options="paymentActionAmountFields"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Paid Flag</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that will be set to true when
|
||||
the invoice is paid.
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Boolean</code> will be
|
||||
shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.paid_flag"
|
||||
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="6"
|
||||
:done="step > 6"
|
||||
title="Publish"
|
||||
icon="publish"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div v-if="g.user.admin" class="row">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.stub_version"
|
||||
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
|
||||
:options="extensionStubVersions.map(f => f.version)"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<q-btn
|
||||
@click="cleanCacheData()"
|
||||
color="grey"
|
||||
outline
|
||||
label="Clean Cache"
|
||||
class="q-ml-md"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
The extension builder uses caching to speed up the build
|
||||
process.
|
||||
</li>
|
||||
<li>
|
||||
This action clears old data and redownloads the Extension
|
||||
Builder Stub release.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="g.user.admin" class="row q-mt-md">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<div class="row">
|
||||
<q-btn
|
||||
@click="buildExtensionAndDeploy()"
|
||||
color="primary"
|
||||
label="Build and Deploy (Admin Only)"
|
||||
class="col"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
Installs the extension directly to this LNbits instance.
|
||||
</li>
|
||||
<li>
|
||||
The extension will be enabled by default, and available to
|
||||
all users.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<div class="row">
|
||||
<q-btn
|
||||
@click="buildExtension()"
|
||||
outline
|
||||
color="gray"
|
||||
label="Download Extension Zip"
|
||||
icon="download"
|
||||
class="col"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
Builds the extension and downloads a zip file with the code.
|
||||
You can then install it manually in your LNbits instance.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<template v-slot:navigation>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
|
||||
<q-btn
|
||||
v-if="step == 1"
|
||||
label="Clear All Data"
|
||||
color="negative"
|
||||
@click="clearAllData"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
flat
|
||||
color="grey-8"
|
||||
class="q-mr-sm"
|
||||
@click="previousStep()"
|
||||
label="Back"
|
||||
icon="chevron_left"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
|
||||
<q-stepper-navigation class="float-right">
|
||||
<q-btn
|
||||
v-if="step < 6"
|
||||
@click="nextStep()"
|
||||
color="primary"
|
||||
label="Next"
|
||||
></q-btn>
|
||||
<template v-else>
|
||||
<q-btn
|
||||
@click="exportJsonData()"
|
||||
color="primary"
|
||||
label="Export JSON Data"
|
||||
></q-btn>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
Exports the config JSON so it can be later imported or
|
||||
shared.
|
||||
</li>
|
||||
<li>
|
||||
This JSON is also added to the zip in a file called
|
||||
`builder.json`.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-stepper-navigation>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-stepper>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md"></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,150 +0,0 @@
|
||||
{% extends "public.html" %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong v-text="$t('welcome_lnbits')"></strong>
|
||||
<p><span v-text="$t('setup_su_account')"></span></p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/first_install',
|
||||
null,
|
||||
{
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
}
|
||||
)
|
||||
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
@@ -1,710 +0,0 @@
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<style>
|
||||
:root {
|
||||
--size: 100px;
|
||||
--gap: 25px;
|
||||
}
|
||||
|
||||
.btn-fixed-width {
|
||||
/* width: 45%; */
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
gap: var(--gap);
|
||||
height: max-content;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
hsl(0 0% 0% / 0),
|
||||
hsl(0 0% 0% / 1) 20%,
|
||||
hsl(0 0% 0% / 1) 80%,
|
||||
hsl(0 0% 0% / 0)
|
||||
);
|
||||
}
|
||||
|
||||
.marquee__group {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--gap);
|
||||
min-width: 100%;
|
||||
animation: scroll-x 60s linear infinite;
|
||||
}
|
||||
|
||||
.marquee:hover .marquee__group {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.marquee__group div {
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
@keyframes scroll-x {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page
|
||||
class="q-px-md q-py-lg content-center"
|
||||
:class="{'q-px-lg': $q.screen.gt.xs}"
|
||||
>
|
||||
{% block page %}
|
||||
<div
|
||||
class="row justify-center items-center"
|
||||
style="min-height: calc(100vh / 1.618)"
|
||||
>
|
||||
<div
|
||||
class="full-width"
|
||||
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
|
||||
>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<h5
|
||||
class="q-my-none"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h5>
|
||||
<template v-if="$q.screen.gt.sm">
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TAGLINE}}
|
||||
</h6>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_DESCRIPTION}}
|
||||
</p>
|
||||
</template>
|
||||
<!-- <div
|
||||
class="gt-sm"
|
||||
v-html="formatDescription"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
></div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<q-badge
|
||||
v-if="isAccessTokenExpired"
|
||||
class="q-mx-auto q-mb-md"
|
||||
color="primary"
|
||||
rounded
|
||||
>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('session_has_expired')"></span>
|
||||
</div>
|
||||
</q-badge>
|
||||
<q-card bordered class="full-width q-py-md">
|
||||
<div class="row">
|
||||
<div
|
||||
class="col-12"
|
||||
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
|
||||
>
|
||||
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
|
||||
in LNBITS_AUTH_METHODS)%}
|
||||
<div class="full-height content-center">
|
||||
<q-card-section>
|
||||
<div class="text-body1">
|
||||
<span v-text="$t('claim_desc')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
{%else%}
|
||||
<username-password
|
||||
v-if="authMethod != 'user-id-only'"
|
||||
:allowed_new_users="allowedRegister"
|
||||
:auth-methods="LNBITS_AUTH_METHODS"
|
||||
:auth-action="authAction"
|
||||
v-model:user-name="username"
|
||||
v-model:password_1="password"
|
||||
v-model:password_2="passwordRepeat"
|
||||
v-model:reset-key="reset_key"
|
||||
@login="login"
|
||||
@register="register"
|
||||
@reset="reset"
|
||||
>
|
||||
<div
|
||||
class="text-center text-grey-6"
|
||||
v-if="authAction !== 'reset'"
|
||||
>
|
||||
<p
|
||||
v-if="authAction === 'login' && allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
Not registered?
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer"
|
||||
@click.prevent="showRegister('username-password')"
|
||||
>Create an Account</a
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="authAction === 'login' && !allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
<span v-text="$t('new_user_not_allowed')"></span>
|
||||
</p>
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click.prevent="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
|
||||
<user-id-only
|
||||
:allowed_new_users="allowedRegister"
|
||||
v-model:usr="usr"
|
||||
v-model:wallet="walletName"
|
||||
:auth-action="authAction"
|
||||
:auth-method="authMethod"
|
||||
@show-login="showLogin"
|
||||
@show-register="showRegister"
|
||||
@login-usr="loginUsr"
|
||||
@create-wallet="createWallet"
|
||||
>
|
||||
</user-id-only>
|
||||
{%endif%} {% endif %}
|
||||
</div>
|
||||
<div
|
||||
class="col-sm-5 col-lg-6 gt-xs"
|
||||
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
>
|
||||
<div class="full-height flex flex-center q-pa-lg">
|
||||
<q-img
|
||||
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
:ratio="1"
|
||||
width="250px"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width q-mb-lg q-mt-sm"
|
||||
>
|
||||
<div class="flex flex-center q-gutter-md q-py-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
class=""
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
class=""
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="q-pt-md full-width">
|
||||
<div class="row justify-center q-mb-xl">
|
||||
<div class="full-width text-center">
|
||||
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
|
||||
</div>
|
||||
<div class="flex flex-center columm">
|
||||
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="flex flex-center column q-pr-sm">
|
||||
<a href="{{ AD[0] }}">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="{{ AD[2] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<div class="marquee">
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="showAddWalletDialog.show = true"
|
||||
:label="$t('add_wallet')"
|
||||
color="primary"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="float-left">
|
||||
<q-input
|
||||
:label="$t('search_wallets')"
|
||||
dense
|
||||
class="float-right q-pr-xl"
|
||||
v-model="walletsTable.search"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search"> </q-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
v-if="walletsTable.search !== ''"
|
||||
name="close"
|
||||
@click="walletsTable.search = ''"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<q-table
|
||||
grid
|
||||
grid-header
|
||||
flat
|
||||
bordered
|
||||
:rows="wallets"
|
||||
:columns="walletsTable.columns"
|
||||
v-model:pagination="walletsTable.pagination"
|
||||
:loading="walletsTable.loading"
|
||||
@request="getUserWallets"
|
||||
row-key="id"
|
||||
:filter="filter"
|
||||
hide-header
|
||||
>
|
||||
<template v-slot:item="props">
|
||||
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
|
||||
<q-card
|
||||
class="q-ma-sm cursor-pointer wallet-list-card"
|
||||
style="text-decoration: none"
|
||||
@click="goToWallet(props.row.id)"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="row items-center">
|
||||
<q-avatar
|
||||
size="lg"
|
||||
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
|
||||
:color="props.row.extra.color"
|
||||
:icon="props.row.extra.icon"
|
||||
>
|
||||
</q-avatar>
|
||||
|
||||
<div
|
||||
class="text-h6 q-pl-md ellipsis"
|
||||
class="text-bold"
|
||||
v-text="props.row.name"
|
||||
></div>
|
||||
<q-space> </q-space>
|
||||
<q-btn
|
||||
v-if="props.row.extra.pinned"
|
||||
round
|
||||
color="primary"
|
||||
text-color="black"
|
||||
size="xs"
|
||||
icon="push_pin"
|
||||
class="float-right"
|
||||
style="transform: rotate(30deg)"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-pt-sm">
|
||||
<h6 class="q-my-none ellipsis full-width">
|
||||
<strong
|
||||
v-text="formatBalance(props.row.balance_msat / 1000)"
|
||||
></strong>
|
||||
</h6>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="text-left">
|
||||
<small>
|
||||
<strong>
|
||||
<span v-text="$t('currency')"></span>
|
||||
</strong>
|
||||
<span v-text="props.row.currency || 'sat'"></span>
|
||||
</small>
|
||||
<br />
|
||||
<small>
|
||||
<strong>
|
||||
<span v-text="$t('id')"></span>
|
||||
:
|
||||
</strong>
|
||||
<span v-text="props.row.id"></span>
|
||||
</small>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog
|
||||
v-model="showAddWalletDialog.show"
|
||||
persistent
|
||||
@hide="showAddWalletDialog = {show: false}"
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('wallet_name')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
dense
|
||||
v-model="showAddWalletDialog.name"
|
||||
autofocus
|
||||
@keyup.enter="submitAddWallet()"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
@@ -1,379 +0,0 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="connectPeerDialog.data.uri"
|
||||
label="Node URI"
|
||||
hint="pubkey@host:port"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('connect')"
|
||||
color="primary"
|
||||
@click="connectPeer"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="setFeeDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<label class="text-h6">Set Channel Fee</label>
|
||||
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
|
||||
<q-separator></q-separator>
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_ppm"
|
||||
label="Fee Rate PPM"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_base_msat"
|
||||
label="Fee Base msat"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('set')"
|
||||
color="primary"
|
||||
@click="setChannelFee(setFeeDialog.channel_id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="openChannelDialog.data.peer_id"
|
||||
label="Peer ID"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.funding_amount"
|
||||
label="Funding Amount"
|
||||
></q-input>
|
||||
<q-expansion-item icon="warning" label="Advanced">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="column q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.push_amount"
|
||||
label="Push Amount"
|
||||
hint="This gifts sats to the other side!"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.fee_rate"
|
||||
label="Fee Rate"
|
||||
></q-input>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('open')"
|
||||
color="primary"
|
||||
@click="openChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('close')"
|
||||
color="primary"
|
||||
@click="closeChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="q-gutter-y-sm">
|
||||
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="channels.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-select
|
||||
dense
|
||||
size="sm"
|
||||
style="min-width: 200px"
|
||||
filled
|
||||
multiple
|
||||
clearable
|
||||
v-model="stateFilters"
|
||||
:options="this.states"
|
||||
class="col-auto"
|
||||
></q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
class="col-auto"
|
||||
@click="showOpenChannelDialog()"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="this.totalBalance"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="this.filteredChannels"
|
||||
:filter="channels.filter"
|
||||
no-data-label="No channels opened"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1" v-text="props.row.name"></div>
|
||||
<div class="text-caption" v-if="props.row.peer_id">
|
||||
<span>Peer ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.peer_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption col-grow">
|
||||
<span>Fees</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="settings"
|
||||
@click="showSetFeeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
<span v-if="props.row.fee_ppm">
|
||||
<span v-text="props.row.fee_ppm"></span> ppm,
|
||||
<span v-text="props.row.fee_base_msat"></span> msat
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.id">
|
||||
<span>Channel ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
<span v-text="props.row.short_id"></span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.short_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
v-text="states.find(s => s.value == props.row.state)?.label"
|
||||
>
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="showCloseChannelDialog(props.row)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<lnbits-channel-balance
|
||||
:balance="props.row.balance"
|
||||
:color="props.row.color"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="peers.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-btn
|
||||
class="col-auto"
|
||||
color="primary"
|
||||
@click="connectPeerDialog.show = true"
|
||||
>
|
||||
Connect Peer
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="peers.data"
|
||||
:filter="peers.filter"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div
|
||||
class="text-subtitle1 text-bold"
|
||||
v-text="props.row.alias"
|
||||
></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
v-text="'#'+props.row.color"
|
||||
>
|
||||
</q-badge>
|
||||
<div
|
||||
class="text-bold"
|
||||
v-text="shortenNodeId(props.row.id)"
|
||||
></div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showOpenChannelDialog(props.row.id)"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="disconnectPeer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,68 +0,0 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('total_capacity')"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Fees collected"
|
||||
:msat="this.info.fees?.total_msat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Balance"
|
||||
:btc="this.info.onchain_balance_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Confirmed"
|
||||
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats
|
||||
:stats="this.channel_stats"
|
||||
></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,314 +0,0 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show" position="top">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
</div>
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
<span
|
||||
v-text="transactionDetailsDialog.data.payment_hash"
|
||||
></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
<span v-text="transactionDetailsDialog.data.preimage"></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
class="q-mt-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Payments</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_made"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog
|
||||
v-model="props.row.expand"
|
||||
:props="props"
|
||||
position="top"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
<div
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_sent')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-date="props">
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-destination="props">
|
||||
<q-td auto-width key="destination">
|
||||
<div class="row items-center justify-between no-wrap">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
v-text="props.row.destination?.alias"
|
||||
></q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(info.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row.destination)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Invoices</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
v-model:pagination="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_received"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-paid_at="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.paid_at"
|
||||
:ts="props.row.paid_at"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-expiry="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.expiry"
|
||||
:ts="props.row.expiry"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,46 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show" position="top">
|
||||
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="dashboard"
|
||||
:label="$t('dashboard')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="channels"
|
||||
:label="$t('channels')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="transactions"
|
||||
:label="$t('transactions')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "node/_tab_dashboard.html" %} {% include
|
||||
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
|
||||
%}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,133 +0,0 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block page %}
|
||||
|
||||
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('total_capacity')"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
channels: [],
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: [],
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
]
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, data) {
|
||||
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
|
||||
},
|
||||
getInfo: function () {
|
||||
this.api('GET', '/info', {}).then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
this.api('GET', '/rank', {}).then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,432 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col"></div>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentStatus"
|
||||
:label="$t('payments_status_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentTags"
|
||||
:label="$t('payments_tag_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalance"
|
||||
:label="$t('payments_balance_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showWalletsSize"
|
||||
:label="$t('payments_wallets_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalanceInOut"
|
||||
:label="$t('payments_balance_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentCountInOut"
|
||||
:label="$t('payments_count_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<q-btn icon="event" outline flat>
|
||||
<q-popup-proxy
|
||||
cover
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
label="Search"
|
||||
@click="searchByDate()"
|
||||
color="primary"
|
||||
flat
|
||||
class="float-left"
|
||||
v-close-popup
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="clearDateSeach()"
|
||||
label="Clear"
|
||||
class="float-right"
|
||||
color="grey"
|
||||
flat
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
<q-badge
|
||||
v-if="searchDate?.to || searchDate?.from"
|
||||
class="q-mt-lg q-mr-md"
|
||||
color="primary"
|
||||
rounded
|
||||
floating
|
||||
style="border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#server"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!showDetails">
|
||||
<div class="row q-col-gutter-md justify-center q-mb-md">
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_status')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tags')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showBalance"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong
|
||||
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
|
||||
></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showWalletsSize"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="chartData.showBalanceInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_balance_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentCountInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_count_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="payment_hash"
|
||||
:rows="payments"
|
||||
:columns="paymentsTable.columns"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
:filter="paymentsTable.search"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchPaymentsBy()"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchPaymentsBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn
|
||||
v-else-if="['status'].includes(col.name)"
|
||||
flat
|
||||
dense
|
||||
:label="$q.screen.gt.md ? 'Status' : null"
|
||||
icon="filter_alt"
|
||||
color="grey"
|
||||
class="text-capitalize"
|
||||
>
|
||||
<q-menu anchor="top right" self="top start">
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.success"
|
||||
@click="handleFilterChanged"
|
||||
label="Success Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.pending"
|
||||
@click="handleFilterChanged"
|
||||
label="Pending Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.failed"
|
||||
@click="handleFilterChanged"
|
||||
label="Failed Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.incoming"
|
||||
@click="handleFilterChanged"
|
||||
label="Incoming Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.outgoing"
|
||||
@click="handleFilterChanged"
|
||||
label="Outgoing Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
</q-menu>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('filter_payments')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-select
|
||||
v-else-if="['tag'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div v-if="col.name == 'status'">
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_details')"></span
|
||||
></q-tooltip>
|
||||
<q-icon
|
||||
@click="showDetailsToggle(props.row)"
|
||||
v-if="props.row.status === 'success'"
|
||||
size="14px"
|
||||
:name="props.row.outgoing ? 'call_made' : 'call_received'"
|
||||
:color="props.row.outgoing ? 'pink' : 'green'"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else-if="props.row.status === 'pending'"
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="downloading"
|
||||
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
|
||||
color="grey"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="warning"
|
||||
color="yellow"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="col.name == 'created_at'">
|
||||
<div>
|
||||
<q-tooltip anchor="top middle">
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
</q-tooltip>
|
||||
<span v-text="props.row.timeFrom"> </span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="props.row[col.name]"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row[col.name])"
|
||||
>
|
||||
<q-tooltip anchor="top middle">Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name], col.max_length)">
|
||||
</span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showDetails">
|
||||
<q-card>
|
||||
<q-card-section class="flex">
|
||||
<div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
class="q-mr-md"
|
||||
@click="showDetailsToggle(null)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="self-center text-h6 text-weight-bolder text-grey-5">
|
||||
<span v-text="$t('payment_details_back')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="text-h6">
|
||||
<q-item>
|
||||
<q-item-section avatar class="">
|
||||
<q-icon color="primary" name="receipt" size="44px"></q-icon>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('payment_details')"></span>
|
||||
</div>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-text="$t('payment_details_desc')">
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-list separator>
|
||||
<q-item v-for="(value, key) in paymentDetails" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label v-text="key"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="value"
|
||||
style="word-wrap: break-word"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
v-show="value"
|
||||
icon="content_copy"
|
||||
flat
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(value)"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
<!-- <q-separator></q-separator> -->
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,44 +0,0 @@
|
||||
<q-dialog v-model="createWalletDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>Create Wallet</strong>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-input
|
||||
v-model="createWalletDialog.data.name"
|
||||
:label='$t("name_your_wallet")'
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="createWalletDialog.data.currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="createWallet()"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -1,196 +0,0 @@
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="activeUser.data.id"
|
||||
@click="updateUser()"
|
||||
color="primary"
|
||||
:label="$t('update_account')"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
@click="createUser()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card v-if="activeUser.show" class="q-pa-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
|
||||
<span v-else v-text="$t('create_account')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-if="activeUser.data.id"
|
||||
v-model="activeUser.data.id"
|
||||
:label="$t('user_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="activeUser.data.showUserId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
><q-btn
|
||||
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!activeUser.data.id"
|
||||
color="secondary"
|
||||
:label="$t('set_password')"
|
||||
v-model="activeUser.setPassword"
|
||||
>
|
||||
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
|
||||
</q-toggle>
|
||||
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password_repeat"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="activeUser.data.pubkey"
|
||||
:label="'Nostr '+ $t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.email"
|
||||
:label="$t('email')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="activeUser.data.extra">
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.external_id"
|
||||
:label="$t('external_id')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="activeUser.data.extensions"
|
||||
multiple
|
||||
label="User extensions"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="activeUser.data.id">
|
||||
<q-btn
|
||||
@click="resetPassword(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('reset_password')"
|
||||
icon="refresh"
|
||||
color="primary"
|
||||
>
|
||||
<q-tooltip>Generate and copy password reset url</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="deleteUser(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('delete')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>Delete User</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -1,190 +0,0 @@
|
||||
<div v-if="paymentPage.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="paymentPage.show = false"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<q-card-section>
|
||||
<payment-list :wallet="paymentsWallet" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row">
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="deleteAllUserWallets(activeWallet.userId)"
|
||||
:label="$t('delete_all_wallets')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||
<q-table :rows="wallets" :columns="walletTable.columns">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width v-if="g.user.super_user"></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
auto-width
|
||||
v-for="col in props.cols"
|
||||
v-text="col.label"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width v-if="g.user.super_user">
|
||||
<lnbits-update-balance
|
||||
:wallet_id="props.row.id"
|
||||
@credit-value="handleBalanceUpdate"
|
||||
class="q-mr-md"
|
||||
></lnbits-update-balance>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="showPayments(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.adminkey)"
|
||||
>
|
||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.inkey)"
|
||||
>
|
||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||
>
|
||||
<q-tooltip>Delete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.deleted"
|
||||
icon="toggle_off"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||
>
|
||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
icon="link"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyWalletLink(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet Link</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<span
|
||||
v-text="props.row.name"
|
||||
v-if="!props.row.editable"
|
||||
:class="props.row.deleted ? 'text-strike' : 'cursor-pointer'"
|
||||
@click="props.row.editable = true && !props.row.deleted"
|
||||
></span>
|
||||
<q-input
|
||||
v-else
|
||||
@keydown.enter="updateWallet(props.row)"
|
||||
v-model="props.row.name"
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
@click="updateWallet(props.row)"
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
flat
|
||||
icon="send"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span
|
||||
v-text="props.row.id"
|
||||
:class="props.row.deleted ? 'text-strike' : ''"
|
||||
></span>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width v-text="props.row.currency"></q-td>
|
||||
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
@@ -1,186 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
{% include "users/_manageWallet.html" %}
|
||||
<div v-if="activeUser.show" class="row">
|
||||
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
{%include "users/_createWalletDialog.html" %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="showAccountPage()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#users"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="id"
|
||||
:rows="users"
|
||||
:columns="usersTable.columns"
|
||||
v-model:pagination="usersTable.pagination"
|
||||
:no-data-label="$t('no_users')"
|
||||
:filter="usersTable.search"
|
||||
:loading="usersTable.loading"
|
||||
@request="fetchUsers"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchUserBy(col.name)"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchUserBy(col.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showAccountPage(props.row.id)"
|
||||
round
|
||||
icon="edit"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!props.row.is_super_user"
|
||||
color="secondary"
|
||||
v-model="props.row.is_admin"
|
||||
@update:model-value="toggleAdmin(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-toggle>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.is_super_user"
|
||||
icon="verified"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>Super User</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="list"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
:label="props.row.wallet_count"
|
||||
@click="fetchWallets(props.row.id)"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="(users.length == 1) && searchData.wallet_id"
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-sm"
|
||||
@click="showWalletPayments(searchData.wallet_id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy User ID</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.id)"></span>
|
||||
</q-td>
|
||||
<q-td v-text="props.row.username"></q-td>
|
||||
|
||||
<q-td v-text="props.row.email"></q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
v-if="props.row.pubkey"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.pubkey)"
|
||||
>
|
||||
<q-tooltip>Copy Public Key</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.pubkey)"></span>
|
||||
</q-td>
|
||||
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
|
||||
<q-td v-text="props.row.transaction_count"></q-td>
|
||||
|
||||
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
+16
-108
@@ -1,20 +1,15 @@
|
||||
import os
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from shutil import make_archive, move
|
||||
from shutil import make_archive
|
||||
from subprocess import Popen
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import IO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import filetype
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.misc import Image, SimpleStatus
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services import (
|
||||
enqueue_admin_notification,
|
||||
get_balance_delta,
|
||||
@@ -23,7 +18,6 @@ from lnbits.core.services import (
|
||||
from lnbits.core.services.notifications import send_email_notification
|
||||
from lnbits.core.services.settings import dict_to_settings
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.helpers import safe_upload_file_path
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
@@ -73,9 +67,9 @@ async def api_test_email():
|
||||
|
||||
@admin_router.get("/api/v1/settings")
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
account: Account = Depends(check_admin),
|
||||
) -> AdminSettings | None:
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
admin_settings = await get_admin_settings(account.is_super_user)
|
||||
return admin_settings
|
||||
|
||||
|
||||
@@ -83,12 +77,14 @@ async def api_get_settings(
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
async def api_update_settings(
|
||||
data: UpdateSettings, account: Account = Depends(check_admin)
|
||||
):
|
||||
enqueue_admin_notification(
|
||||
NotificationType.settings_update, {"username": user.username}
|
||||
NotificationType.settings_update, {"username": account.username}
|
||||
)
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
admin_settings = await get_admin_settings(account.is_super_user)
|
||||
if not admin_settings:
|
||||
raise ValueError("Updated admin settings not found.")
|
||||
update_cached_settings(admin_settings.dict())
|
||||
@@ -100,9 +96,11 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
|
||||
async def api_update_settings_partial(
|
||||
data: dict, account: Account = Depends(check_admin)
|
||||
):
|
||||
updatable_settings = dict_to_settings({**settings.dict(), **data})
|
||||
return await api_update_settings(updatable_settings, user)
|
||||
return await api_update_settings(updatable_settings, account)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
@@ -116,9 +114,9 @@ async def api_reset_settings(field_name: str):
|
||||
|
||||
|
||||
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
||||
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||
async def api_delete_settings(account: Account = Depends(check_super_user)) -> None:
|
||||
enqueue_admin_notification(
|
||||
NotificationType.settings_update, {"username": user.username}
|
||||
NotificationType.settings_update, {"username": account.username}
|
||||
)
|
||||
await reset_core_settings()
|
||||
server_restart.set()
|
||||
@@ -172,93 +170,3 @@ async def api_download_backup() -> FileResponse:
|
||||
return FileResponse(
|
||||
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/v1/images",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def upload_image(
|
||||
file: UploadFile = file_upload,
|
||||
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
|
||||
) -> Image:
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
|
||||
)
|
||||
|
||||
# validate file types
|
||||
file_info = filetype.guess(file.file)
|
||||
if file_info is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="Unable to determine file type",
|
||||
)
|
||||
detected_content_type = file_info.extension.lower()
|
||||
if (
|
||||
file.content_type not in settings.lnbits_upload_allowed_types
|
||||
or detected_content_type not in settings.lnbits_upload_allowed_types
|
||||
):
|
||||
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
|
||||
|
||||
# validate file name
|
||||
try:
|
||||
file_path = safe_upload_file_path(file.filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=f"The requested filename '{file.filename}' is forbidden.",
|
||||
) from e
|
||||
|
||||
# validate file size
|
||||
real_file_size = 0
|
||||
temp: IO = NamedTemporaryFile(delete=False)
|
||||
for chunk in file.file:
|
||||
real_file_size += len(chunk)
|
||||
if real_file_size > content_length:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File too large ({content_length / 1000} KB max)",
|
||||
)
|
||||
temp.write(chunk)
|
||||
temp.close()
|
||||
|
||||
move(temp.name, file_path)
|
||||
return Image(filename=file.filename)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/v1/images",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def list_uploaded_images() -> list[Image]:
|
||||
image_folder = Path(settings.lnbits_data_folder, "images")
|
||||
files = image_folder.glob("*")
|
||||
images = []
|
||||
for file in files:
|
||||
if file.is_file():
|
||||
images.append(Image(filename=file.name))
|
||||
return images
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/v1/images/{filename}",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_uploaded_image(filename: str) -> SimpleStatus:
|
||||
try:
|
||||
file_path = safe_upload_file_path(filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=f"The requested filename '{filename}' is forbidden.",
|
||||
) from e
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
|
||||
|
||||
file_path.unlink()
|
||||
return SimpleStatus(success=True, message=f"{filename} deleted")
|
||||
|
||||
@@ -8,13 +8,17 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateWallet,
|
||||
User,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
@@ -39,7 +43,9 @@ async def health() -> dict:
|
||||
|
||||
|
||||
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
||||
async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||
async def health_check(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
stat: dict[str, Any] = {
|
||||
"server_time": int(time()),
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
@@ -47,7 +53,7 @@ async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||
}
|
||||
|
||||
stat["version"] = settings.version
|
||||
if not user.admin:
|
||||
if not account_id.is_admin_id:
|
||||
return stat
|
||||
|
||||
funding_source = get_funding_source()
|
||||
@@ -64,7 +70,6 @@ async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||
"/api/v1/wallets",
|
||||
name="Wallets",
|
||||
description="Get basic info for all of user's wallets.",
|
||||
response_model=list[BaseWallet],
|
||||
)
|
||||
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
|
||||
return user.wallets
|
||||
@@ -78,7 +83,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/rate/history",
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
)
|
||||
async def api_exchange_rate_history() -> list[dict]:
|
||||
return settings.lnbits_exchange_rate_history
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import base64
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
|
||||
from lnbits.core.crud.assets import (
|
||||
delete_user_asset,
|
||||
get_asset_info,
|
||||
get_public_asset,
|
||||
get_public_asset_info,
|
||||
get_user_asset,
|
||||
get_user_asset_info,
|
||||
get_user_assets,
|
||||
update_user_asset_info,
|
||||
)
|
||||
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.services.assets import create_user_asset
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
optional_user_id,
|
||||
parse_filters,
|
||||
)
|
||||
|
||||
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
|
||||
|
||||
upload_file_param = File(...)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
"/paginated",
|
||||
name="Get user assets",
|
||||
summary="Get paginated list user assets",
|
||||
)
|
||||
async def api_get_user_assets(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
filters: Filters = Depends(parse_filters(AssetFilters)),
|
||||
) -> Page[AssetInfo]:
|
||||
return await get_user_assets(account_id.id, filters=filters)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
"/{asset_id}",
|
||||
name="Get user asset",
|
||||
summary="Get user asset by ID",
|
||||
)
|
||||
async def api_get_asset(
|
||||
asset_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> AssetInfo:
|
||||
asset_info = await get_user_asset_info(account_id.id, asset_id)
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
return asset_info
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
"/{asset_id}/binary",
|
||||
name="Get user asset binary",
|
||||
summary="Get user asset binary data by ID",
|
||||
)
|
||||
async def api_get_asset_binary(
|
||||
asset_id: str,
|
||||
user_id: str | None = Depends(optional_user_id),
|
||||
) -> Response:
|
||||
asset = None
|
||||
if user_id:
|
||||
asset = await get_user_asset(user_id, asset_id)
|
||||
|
||||
if not asset:
|
||||
asset = await get_public_asset(asset_id)
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return Response(
|
||||
content=asset.data,
|
||||
media_type=asset.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@asset_router.get(
|
||||
"/{asset_id}/thumbnail",
|
||||
name="Get user asset thumbnail",
|
||||
summary="Get user asset thumbnail data by ID",
|
||||
)
|
||||
async def api_get_asset_thumbnail(
|
||||
asset_id: str,
|
||||
user_id: str | None = Depends(optional_user_id),
|
||||
) -> Response:
|
||||
asset_info = None
|
||||
if user_id:
|
||||
asset_info = await get_user_asset_info(user_id, asset_id)
|
||||
|
||||
if not asset_info:
|
||||
asset_info = await get_public_asset_info(asset_id)
|
||||
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
return Response(
|
||||
content=(
|
||||
base64.b64decode(asset_info.thumbnail_base64)
|
||||
if asset_info.thumbnail_base64
|
||||
else b""
|
||||
),
|
||||
media_type=asset_info.mime_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@asset_router.put(
|
||||
"/{asset_id}",
|
||||
name="Update user asset",
|
||||
summary="Update user asset by ID",
|
||||
)
|
||||
async def api_update_asset(
|
||||
asset_id: str,
|
||||
data: AssetUpdate,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> AssetInfo:
|
||||
if account_id.is_admin_id:
|
||||
asset_info = await get_asset_info(asset_id)
|
||||
else:
|
||||
asset_info = await get_user_asset_info(account_id.id, asset_id)
|
||||
|
||||
if not asset_info:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
asset_info.name = data.name or asset_info.name
|
||||
asset_info.is_public = (
|
||||
asset_info.is_public if data.is_public is None else data.is_public
|
||||
)
|
||||
await update_user_asset_info(asset_info)
|
||||
return asset_info
|
||||
|
||||
|
||||
@asset_router.post(
|
||||
"",
|
||||
name="Upload",
|
||||
summary="Upload user assets",
|
||||
)
|
||||
async def api_upload_asset(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
file: UploadFile = upload_file_param,
|
||||
public_asset: bool = False,
|
||||
) -> AssetInfo:
|
||||
asset = await create_user_asset(account_id.id, file, public_asset)
|
||||
|
||||
asset_info = await get_user_asset_info(account_id.id, asset.id)
|
||||
if not asset_info:
|
||||
raise ValueError("Failed to retrieve asset info after upload.")
|
||||
|
||||
return asset_info
|
||||
|
||||
|
||||
@asset_router.delete(
|
||||
"/{asset_id}",
|
||||
name="Delete user asset",
|
||||
summary="Delete user asset by ID",
|
||||
)
|
||||
async def api_delete_asset(
|
||||
asset_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> SimpleStatus:
|
||||
asset = await get_user_asset(account_id.id, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
|
||||
|
||||
await delete_user_asset(account_id.id, asset_id)
|
||||
return SimpleStatus(success=True, message="Asset deleted successfully.")
|
||||
@@ -25,7 +25,12 @@ from lnbits.core.models.users import (
|
||||
UpdateAccessControlList,
|
||||
)
|
||||
from lnbits.core.services import create_user_account
|
||||
from lnbits.decorators import access_token_payload, check_user_exists
|
||||
from lnbits.core.services.users import update_user_account
|
||||
from lnbits.decorators import (
|
||||
access_token_payload,
|
||||
check_account_exists,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
decrypt_internal_message,
|
||||
@@ -118,11 +123,11 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
@auth_router.get("/acl")
|
||||
async def api_get_user_acls(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> UserAcls:
|
||||
api_routes = get_api_routes(request.app.router.routes)
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acls = await get_user_access_control_lists(account.id)
|
||||
|
||||
# Add missing/new endpoints to the ACLs
|
||||
for acl in acls.access_control_list:
|
||||
@@ -135,7 +140,7 @@ async def api_get_user_acls(
|
||||
acl.endpoints.append(EndpointAccess(path=path, name=name))
|
||||
acl.endpoints.sort(key=lambda e: e.name.lower())
|
||||
|
||||
return UserAcls(id=user.id, access_control_list=acls.access_control_list)
|
||||
return UserAcls(id=account.id, access_control_list=acls.access_control_list)
|
||||
|
||||
|
||||
@auth_router.put("/acl")
|
||||
@@ -143,13 +148,13 @@ async def api_get_user_acls(
|
||||
async def api_update_user_acl(
|
||||
request: Request,
|
||||
data: UpdateAccessControlList,
|
||||
user: User = Depends(check_user_exists),
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> UserAcls:
|
||||
account = await get_account(user.id)
|
||||
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
user_acls = await get_user_access_control_lists(user.id)
|
||||
user_acls = await get_user_access_control_lists(account.id)
|
||||
acl = user_acls.get_acl_by_id(data.id)
|
||||
if acl:
|
||||
user_acls.access_control_list.remove(acl)
|
||||
@@ -174,33 +179,30 @@ async def api_update_user_acl(
|
||||
|
||||
@auth_router.delete("/acl")
|
||||
async def api_delete_user_acl(
|
||||
data: DeleteAccessControlList,
|
||||
user: User = Depends(check_user_exists),
|
||||
data: DeleteAccessControlList, account: Account = Depends(check_account_exists)
|
||||
):
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
user_acls = await get_user_access_control_lists(user.id)
|
||||
user_acls = await get_user_access_control_lists(account.id)
|
||||
user_acls.delete_acl_by_id(data.id)
|
||||
await update_user_access_control_list(user_acls)
|
||||
|
||||
|
||||
@auth_router.post("/acl/token")
|
||||
async def api_create_user_api_token(
|
||||
data: ApiTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
data: ApiTokenRequest, account: Account = Depends(check_account_exists)
|
||||
) -> ApiTokenResponse:
|
||||
if not data.expiration_time_minutes > 0:
|
||||
raise ValueError("Expiration time must be in the future.")
|
||||
account = await get_account(user.id)
|
||||
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acls = await get_user_access_control_lists(account.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
@@ -217,18 +219,16 @@ async def api_create_user_api_token(
|
||||
|
||||
@auth_router.delete("/acl/token")
|
||||
async def api_delete_user_api_token(
|
||||
data: DeleteTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
data: DeleteTokenRequest, account: Account = Depends(check_account_exists)
|
||||
):
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acls = await get_user_access_control_lists(account.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
@@ -317,24 +317,20 @@ async def register(data: RegisterUser) -> JSONResponse:
|
||||
@auth_router.put("/pubkey")
|
||||
async def update_pubkey(
|
||||
data: UpdateUserPubkey,
|
||||
user: User = Depends(check_user_exists),
|
||||
account: Account = Depends(check_account_exists),
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> User | None:
|
||||
if data.user_id != user.id:
|
||||
if data.user_id != account.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if (
|
||||
data.pubkey
|
||||
and data.pubkey != user.pubkey
|
||||
and data.pubkey != account.pubkey
|
||||
and await get_account_by_pubkey(data.pubkey)
|
||||
):
|
||||
raise ValueError("Public key already in use.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
|
||||
|
||||
account.pubkey = normalize_public_key(data.pubkey)
|
||||
await update_account(account)
|
||||
return await get_user_from_account(account)
|
||||
@@ -343,23 +339,19 @@ async def update_pubkey(
|
||||
@auth_router.put("/password")
|
||||
async def update_password(
|
||||
data: UpdateUserPassword,
|
||||
user: User = Depends(check_user_exists),
|
||||
account: Account = Depends(check_account_exists),
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> User | None:
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if data.user_id != user.id:
|
||||
if data.user_id != account.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
and account.username != data.username
|
||||
and await get_account_by_username(data.username)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise ValueError("Account not found.")
|
||||
|
||||
# old accounts do not have a password
|
||||
if account.password_hash:
|
||||
if not data.password_old:
|
||||
@@ -418,30 +410,17 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
|
||||
@auth_router.put("/update")
|
||||
async def update(
|
||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||
data: UpdateUser, account: Account = Depends(check_account_exists)
|
||||
) -> User | None:
|
||||
if data.user_id != user.id:
|
||||
if data.user_id != account.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
if data.username and not is_valid_username(data.username):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
and await get_account_by_username(data.username)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
|
||||
|
||||
if data.username:
|
||||
account.username = data.username
|
||||
if data.extra:
|
||||
account.extra = data.extra
|
||||
|
||||
await update_account(account)
|
||||
await update_user_account(account)
|
||||
return await get_user_from_account(account)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_stripe_signature,
|
||||
verify_paypal_webhook,
|
||||
)
|
||||
from lnbits.core.services.payments import create_fiat_invoice
|
||||
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
|
||||
@@ -37,6 +38,17 @@ async def api_generic_webhook_handler(
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "paypal":
|
||||
payload = await request.body()
|
||||
await verify_paypal_webhook(request.headers, payload)
|
||||
event = await request.json()
|
||||
await handle_paypal_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Unknown fiat provider '{provider_name}'.",
|
||||
@@ -48,6 +60,8 @@ async def handle_stripe_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_stripe_checkout_session_completed(event)
|
||||
elif event_type == "payment_intent.succeeded":
|
||||
await _handle_stripe_intent_session_completed(event)
|
||||
elif event_type == "invoice.paid":
|
||||
await _handle_stripe_subscription_invoice_paid(event)
|
||||
else:
|
||||
@@ -56,18 +70,38 @@ async def handle_stripe_event(event: dict):
|
||||
)
|
||||
|
||||
|
||||
async def _handle_stripe_intent_session_completed(event: dict):
|
||||
event_id = event.get("id")
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
object_type = event_object.get("object")
|
||||
payment_hash = event_object.get("metadata", {}).get("payment_hash")
|
||||
logger.debug(
|
||||
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
|
||||
f" Payment hash: '{payment_hash}'."
|
||||
)
|
||||
if not payment_hash:
|
||||
logger.warning("Stripe event does not contain a payment hash.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
async def _handle_stripe_checkout_session_completed(event: dict):
|
||||
event_id = event.get("id")
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
object_type = event_object.get("object")
|
||||
payment_hash = event_object.get("metadata", {}).get("payment_hash")
|
||||
lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
|
||||
alan_action = event_object.get("metadata", {}).get("alan_action")
|
||||
logger.debug(
|
||||
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
|
||||
f" Payment hash: '{payment_hash}'."
|
||||
)
|
||||
if lnbits_action != "invoice":
|
||||
logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
|
||||
if alan_action != "invoice":
|
||||
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.")
|
||||
return
|
||||
|
||||
if not payment_hash:
|
||||
@@ -132,7 +166,7 @@ async def _get_stripe_subscription_payment_options(
|
||||
|
||||
metadata = parent.get("subscription_details", {}).get("metadata", {})
|
||||
|
||||
if metadata.get("lnbits_action") != "subscription":
|
||||
if metadata.get("alan_action") != "subscription":
|
||||
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
|
||||
|
||||
if "extra" in metadata:
|
||||
@@ -143,3 +177,84 @@ async def _get_stripe_subscription_payment_options(
|
||||
metadata["extra"] = {}
|
||||
|
||||
return FiatSubscriptionPaymentOptions(**metadata)
|
||||
|
||||
|
||||
async def handle_paypal_event(event: dict):
|
||||
event_type = event.get("event_type", "")
|
||||
resource = event.get("resource", {})
|
||||
|
||||
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
|
||||
payment_hash = _paypal_extract_payment_hash(resource)
|
||||
if not payment_hash:
|
||||
logger.warning("PayPal event missing payment hash.")
|
||||
return
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await payment.check_fiat_status()
|
||||
return
|
||||
|
||||
if event_type in (
|
||||
"PAYMENT.SALE.COMPLETED",
|
||||
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
|
||||
):
|
||||
await _handle_paypal_subscription_payment(resource)
|
||||
return
|
||||
|
||||
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def _handle_paypal_subscription_payment(resource: dict):
|
||||
amount_info = resource.get("amount") or {}
|
||||
currency = (amount_info.get("currency_code") or "").upper()
|
||||
total = amount_info.get("value")
|
||||
if not currency or total is None:
|
||||
raise ValueError("PayPal subscription event missing amount.")
|
||||
|
||||
custom_id = resource.get("custom_id") or resource.get("custom")
|
||||
if not custom_id:
|
||||
raise ValueError("PayPal subscription event missing custom metadata.")
|
||||
|
||||
try:
|
||||
metadata = json.loads(custom_id)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
payment_options = FiatSubscriptionPaymentOptions(**metadata)
|
||||
if not payment_options.wallet_id:
|
||||
raise ValueError("PayPal subscription event missing wallet_id.")
|
||||
|
||||
memo = payment_options.memo or ""
|
||||
extra = {
|
||||
**(payment_options.extra or {}),
|
||||
"fiat_method": "subscription",
|
||||
"tag": payment_options.tag,
|
||||
"subscription": {
|
||||
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
|
||||
"payment_request": "",
|
||||
},
|
||||
}
|
||||
|
||||
payment = await create_fiat_invoice(
|
||||
wallet_id=payment_options.wallet_id,
|
||||
invoice_data=CreateInvoice(
|
||||
unit=currency,
|
||||
amount=float(total),
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
fiat_provider="paypal",
|
||||
),
|
||||
)
|
||||
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
def _paypal_extract_payment_hash(resource: dict) -> str | None:
|
||||
purchase_units = resource.get("purchase_units") or []
|
||||
for pu in purchase_units:
|
||||
if pu.get("invoice_id"):
|
||||
return pu.get("invoice_id")
|
||||
if pu.get("custom_id"):
|
||||
return pu.get("custom_id")
|
||||
return None
|
||||
|
||||
@@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
User,
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
@@ -23,6 +24,7 @@ from lnbits.core.models.extensions import (
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
@@ -33,15 +35,18 @@ from lnbits.core.services.extensions import (
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
create_user_extension,
|
||||
delete_dbversion,
|
||||
drop_extension_db,
|
||||
get_db_version,
|
||||
get_db_versions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_user_extension,
|
||||
@@ -140,9 +145,10 @@ async def api_extension_details(
|
||||
async def api_update_pay_to_enable(
|
||||
ext_id: str,
|
||||
data: PayToEnableInfo,
|
||||
user: User = Depends(check_admin),
|
||||
account: Account = Depends(check_admin),
|
||||
) -> SimpleStatus:
|
||||
if data.wallet not in user.wallet_ids:
|
||||
user_wallet_ids = await get_wallets_ids(account.id, deleted=False)
|
||||
if data.wallet not in user_wallet_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
|
||||
)
|
||||
@@ -161,7 +167,7 @@ async def api_update_pay_to_enable(
|
||||
|
||||
@extension_router.put("/{ext_id}/enable")
|
||||
async def api_enable_extension(
|
||||
ext_id: str, user: User = Depends(check_user_exists)
|
||||
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
|
||||
) -> SimpleStatus:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
@@ -175,12 +181,12 @@ async def api_enable_extension(
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
if user.admin or not ext.requires_payment:
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
@@ -217,13 +223,13 @@ async def api_enable_extension(
|
||||
|
||||
@extension_router.put("/{ext_id}/disable")
|
||||
async def api_disable_extension(
|
||||
ext_id: str, user: User = Depends(check_user_exists)
|
||||
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
|
||||
) -> SimpleStatus:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Extension '{ext_id}' already disabled."
|
||||
@@ -374,7 +380,9 @@ async def get_pay_to_install_invoice(
|
||||
|
||||
@extension_router.put("/{ext_id}/invoice/enable")
|
||||
async def get_pay_to_enable_invoice(
|
||||
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
||||
ext_id: str,
|
||||
data: PayToEnableInfo,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
):
|
||||
if not data.amount or data.amount <= 0:
|
||||
raise HTTPException(
|
||||
@@ -420,9 +428,9 @@ async def get_pay_to_enable_invoice(
|
||||
memo=f"Enable '{ext.name}' extension.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
|
||||
user_ext_info.payment_hash_to_enable = payment.payment_hash
|
||||
@@ -433,7 +441,7 @@ async def get_pay_to_enable_invoice(
|
||||
|
||||
@extension_router.get(
|
||||
"/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
@@ -454,15 +462,18 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
|
||||
@extension_router.get("")
|
||||
async def api_get_user_extensions(
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> list[Extension]:
|
||||
|
||||
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
|
||||
return [
|
||||
ext
|
||||
for ext in await get_valid_extensions(False)
|
||||
if ext.code in user_extensions_ids
|
||||
]
|
||||
async with db.connect() as conn:
|
||||
user_extensions_ids = [
|
||||
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn)
|
||||
]
|
||||
valid_extensions = [
|
||||
ext
|
||||
for ext in await get_valid_extensions(False, conn=conn)
|
||||
if ext.code in user_extensions_ids
|
||||
]
|
||||
return valid_extensions
|
||||
|
||||
|
||||
@extension_router.delete(
|
||||
@@ -492,3 +503,89 @@ async def delete_extension_db(ext_id: str):
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
) from exc
|
||||
|
||||
|
||||
# TODO: create a response model for this
|
||||
@extension_router.get("/all")
|
||||
async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
async with db.connect() as conn:
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions(
|
||||
conn=conn
|
||||
)
|
||||
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
|
||||
inactive_extensions = [
|
||||
e.id for e in await get_installed_extensions(active=False, conn=conn)
|
||||
]
|
||||
db_versions = await get_db_versions(conn=conn)
|
||||
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=account_id.is_admin_id
|
||||
)
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
if installed_ext and installed_ext.meta:
|
||||
installed_release = installed_ext.meta.installed_release
|
||||
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
|
||||
# not a security leak, but better not to share the wallet id
|
||||
installed_ext.meta.pay_to_enable.wallet = None
|
||||
pay_to_enable = installed_ext.meta.pay_to_enable
|
||||
|
||||
if e.meta:
|
||||
e.meta.installed_release = installed_release
|
||||
e.meta.pay_to_enable = pay_to_enable
|
||||
else:
|
||||
e.meta = ExtensionMeta(
|
||||
installed_release=installed_release,
|
||||
pay_to_enable=pay_to_enable,
|
||||
)
|
||||
# use the installed extension values
|
||||
e.name = installed_ext.name
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
return extension_data
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import os
|
||||
import shutil
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
User,
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
@@ -17,6 +15,7 @@ from lnbits.core.models.extensions import (
|
||||
UserExtension,
|
||||
)
|
||||
from lnbits.core.models.extensions_builder import ExtensionData
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
install_extension,
|
||||
@@ -27,10 +26,10 @@ from lnbits.core.services.extensions_builder import (
|
||||
zip_directory,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
check_account_id_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
check_extension_builder,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
create_user_extension,
|
||||
@@ -47,19 +46,12 @@ extension_builder_router = APIRouter(
|
||||
@extension_builder_router.post(
|
||||
"/zip",
|
||||
summary="Build and download extension zip.",
|
||||
dependencies=[Depends(check_extension_builder)],
|
||||
description="""
|
||||
This endpoint generates a zip file for the extension based on the provided data.
|
||||
""",
|
||||
)
|
||||
async def api_build_extension(
|
||||
data: ExtensionData,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> FileResponse:
|
||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Extension Builder is disabled for non admin users.",
|
||||
)
|
||||
async def api_build_extension(data: ExtensionData) -> FileResponse:
|
||||
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
|
||||
release, build_dir = await build_extension_from_data(data, stub_ext_id)
|
||||
|
||||
@@ -92,9 +84,9 @@ async def api_build_extension(
|
||||
)
|
||||
async def api_deploy_extension(
|
||||
data: ExtensionData,
|
||||
user: User = Depends(check_admin),
|
||||
account: Account = Depends(check_admin),
|
||||
) -> SimpleStatus:
|
||||
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||
working_dir_name = "deploy_" + sha256(account.id.encode("utf-8")).hexdigest()
|
||||
stub_ext_id = "extension_builder_stub"
|
||||
release, build_dir = await build_extension_from_data(
|
||||
data, stub_ext_id, working_dir_name
|
||||
@@ -118,9 +110,9 @@ async def api_deploy_extension(
|
||||
|
||||
await activate_extension(Extension.from_installable_ext(ext_info))
|
||||
|
||||
user_ext = await get_user_extension(user.id, data.id)
|
||||
user_ext = await get_user_extension(account.id, data.id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
|
||||
user_ext = UserExtension(user=account.id, extension=data.id, active=True)
|
||||
await create_user_extension(user_ext)
|
||||
elif not user_ext.active:
|
||||
user_ext.active = True
|
||||
@@ -132,18 +124,14 @@ async def api_deploy_extension(
|
||||
@extension_builder_router.post(
|
||||
"/preview",
|
||||
summary="Build and preview the extension ui.",
|
||||
dependencies=[Depends(check_extension_builder)],
|
||||
)
|
||||
async def api_preview_extension(
|
||||
data: ExtensionData,
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> SimpleStatus:
|
||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Extension Builder is disabled for non admin users.",
|
||||
)
|
||||
stub_ext_id = "extension_builder_stub"
|
||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||
working_dir_name = "preview_" + sha256(account_id.id.encode("utf-8")).hexdigest()
|
||||
await build_extension_from_data(data, stub_ext_id, working_dir_name)
|
||||
|
||||
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
|
||||
|
||||
@@ -75,20 +75,28 @@ async def cancel_subscription(
|
||||
)
|
||||
async def connection_token(provider: str):
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if provider == "stripe":
|
||||
if not isinstance(fiat_provider, StripeWallet):
|
||||
if not fiat_provider:
|
||||
raise HTTPException(status_code=404, detail="Fiat provider not found")
|
||||
|
||||
if provider != "stripe":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Connection tokens are not supported for provider '{provider}'.",
|
||||
)
|
||||
|
||||
if not isinstance(fiat_provider, StripeWallet):
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Stripe wallet/provider not configured"
|
||||
)
|
||||
try:
|
||||
tok = await fiat_provider.create_terminal_connection_token()
|
||||
secret = tok.get("secret")
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Stripe wallet/provider not configured"
|
||||
status_code=502, detail="Stripe returned no connection token"
|
||||
)
|
||||
try:
|
||||
tok = await fiat_provider.create_terminal_connection_token()
|
||||
secret = tok.get("secret")
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Stripe returned no connection token"
|
||||
)
|
||||
return {"secret": secret}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create connection token"
|
||||
) from e
|
||||
return {"secret": secret}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create connection token"
|
||||
) from e
|
||||
|
||||
+39
-342
@@ -1,35 +1,29 @@
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Cookie, Depends, Query, Request
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from lnurl import url_decode
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
|
||||
from lnbits.core.services import create_invoice, create_user_account
|
||||
from lnbits.core.services.extensions import get_valid_extensions
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
check_admin_ui,
|
||||
check_extension_builder,
|
||||
check_first_install,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url, template_renderer
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
get_db_versions,
|
||||
get_installed_extensions,
|
||||
get_user,
|
||||
get_wallet,
|
||||
)
|
||||
from ..crud import get_user
|
||||
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
@@ -41,251 +35,23 @@ async def favicon():
|
||||
return RedirectResponse(settings.lnbits_qr_logo)
|
||||
|
||||
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "core/index.html", {"lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/account",
|
||||
response_class=HTMLResponse,
|
||||
description="show account page",
|
||||
)
|
||||
async def account(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
nostr_configured = settings.is_nostr_notifications_configured()
|
||||
telegram_configured = settings.is_telegram_notifications_configured()
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/account.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"nostr_configured": nostr_configured,
|
||||
"telegram_configured": telegram_configured,
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
description="show wallet page",
|
||||
)
|
||||
async def get_user_wallet(
|
||||
request: Request,
|
||||
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
wal: UUID4 | None = Query(None),
|
||||
):
|
||||
if wal:
|
||||
wallet = await get_wallet(wal.hex)
|
||||
elif len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
user.wallets.append(wallet)
|
||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||
wallet = await get_wallet(lnbits_last_active_wallet)
|
||||
else:
|
||||
wallet = user.wallets[0]
|
||||
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
if wallet.user != user.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Not your wallet.",
|
||||
)
|
||||
context = {
|
||||
"user": user.json(),
|
||||
"wallet": wallet.json(),
|
||||
"wallet_name": wallet.name,
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
}
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallet.html",
|
||||
{**context, "ajax": _is_ajax_request(request)},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/wallets",
|
||||
response_class=HTMLResponse,
|
||||
description="show wallets page",
|
||||
)
|
||||
async def wallets(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallets.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/first_install", response_class=HTMLResponse)
|
||||
async def first_install(request: Request):
|
||||
if not settings.first_install:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Super user account has already been configured.",
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/first_install.html",
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||
async def robots():
|
||||
data = """
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
"""
|
||||
data = "User-agent: *\nDisallow: /"
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
if installed_ext and installed_ext.meta:
|
||||
installed_release = installed_ext.meta.installed_release
|
||||
if installed_ext.meta.pay_to_enable and not user.admin:
|
||||
# not a security leak, but better not to share the wallet id
|
||||
installed_ext.meta.pay_to_enable.wallet = None
|
||||
pay_to_enable = installed_ext.meta.pay_to_enable
|
||||
|
||||
if e.meta:
|
||||
e.meta.installed_release = installed_release
|
||||
e.meta.pay_to_enable = pay_to_enable
|
||||
else:
|
||||
e.meta = ExtensionMeta(
|
||||
installed_release=installed_release,
|
||||
pay_to_enable=pay_to_enable,
|
||||
)
|
||||
# use the installed extension values
|
||||
e.name = installed_ext.name
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
|
||||
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
|
||||
db_versions = await get_db_versions()
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
|
||||
# refresh user state. Eg: enabled extensions.
|
||||
# TODO: refactor
|
||||
# user = await get_user(user.id) or user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/extensions.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"extension_data": extension_data,
|
||||
"extension_builder_enabled": user.admin
|
||||
or settings.lnbits_extensions_builder_activate_non_admins,
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
|
||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Extension Builder is disabled for non admin users.",
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/extensions_builder.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/extensions/builder/preview/{ext_id}",
|
||||
name="extensions builder",
|
||||
response_class=HTMLResponse,
|
||||
dependencies=[Depends(check_extension_builder)],
|
||||
)
|
||||
async def extensions_builder_preview(
|
||||
request: Request,
|
||||
ext_id: str,
|
||||
page_name: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Extension Builder is disabled for non admin users.",
|
||||
)
|
||||
) -> HTMLResponse:
|
||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
||||
html_file_name = "index.html"
|
||||
if page_name == "public_page":
|
||||
@@ -310,8 +76,8 @@ async def extensions_builder_preview(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"err": f"Extension {ext_id} not found",
|
||||
"message": "Please 'Refresh Preview' first.",
|
||||
"status_code": 404,
|
||||
"message": f"Extension {ext_id} not found, refresh Preview.",
|
||||
},
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
@@ -321,7 +87,6 @@ async def extensions_builder_preview(
|
||||
html_file_path.as_posix(),
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -330,6 +95,7 @@ async def extensions_builder_preview(
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -359,7 +125,7 @@ async def manifest(request: Request, usr: str):
|
||||
"src": (
|
||||
settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
|
||||
else "images/logos/lnbits.png"
|
||||
),
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
@@ -426,104 +192,39 @@ async def manifest(request: Request, usr: str):
|
||||
}
|
||||
|
||||
|
||||
@generic_router.get("/node", response_class=HTMLResponse)
|
||||
async def node(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
@generic_router.get("/payments")
|
||||
@generic_router.get("/wallet")
|
||||
@generic_router.get("/wallet/{wallet_id}")
|
||||
@generic_router.get("/wallets")
|
||||
@generic_router.get("/account")
|
||||
@generic_router.get("/extensions")
|
||||
@generic_router.get("/users", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/audit", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/node", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin", dependencies=admin_ui_checks)
|
||||
@generic_router.get(
|
||||
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/index.html",
|
||||
"index.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||
async def node_public(request: Request):
|
||||
if not settings.lnbits_public_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/public.html",
|
||||
{
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"admin/index.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/users", response_class=HTMLResponse)
|
||||
async def users_index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"users/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_audit_enabled:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"audit/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return template_renderer().TemplateResponse(
|
||||
"payments/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
@generic_router.get("/")
|
||||
@generic_router.get("/node/public")
|
||||
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
|
||||
async def index_public(request: Request) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
@@ -584,7 +285,3 @@ async def lnurlwallet(request: Request, lightning: str = ""):
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={account.id}&wal={wallet.id}",
|
||||
)
|
||||
|
||||
|
||||
def _is_ajax_request(request: Request):
|
||||
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"
|
||||
|
||||
@@ -6,16 +6,17 @@ from fastapi import (
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from lnurl import LnurlResponseException
|
||||
from lnurl import execute_login as lnurlauth
|
||||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import (
|
||||
from lnurl import (
|
||||
LnurlAuthResponse,
|
||||
LnurlErrorResponse,
|
||||
LnurlException,
|
||||
LnurlPayResponse,
|
||||
LnurlResponseModel,
|
||||
LnurlResponseException,
|
||||
LnurlWithdrawResponse,
|
||||
)
|
||||
from lnurl import execute_login as lnurlauth
|
||||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import LnurlResponseModel
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
@@ -23,7 +24,7 @@ from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
require_base_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
@@ -38,7 +39,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
|
||||
if isinstance(res, LnurlErrorResponse):
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
|
||||
except LnurlResponseException as exc:
|
||||
except LnurlException as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
@@ -47,7 +48,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurlscan/{code}",
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
dependencies=[Depends(require_base_invoice_key)],
|
||||
deprecated=True,
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
@@ -63,7 +64,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
||||
|
||||
@lnurl_router.post(
|
||||
"/api/v1/lnurlscan",
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
dependencies=[Depends(require_base_invoice_key)],
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
| LnurlAuthResponse
|
||||
|
||||
@@ -3,7 +3,6 @@ from http import HTTPStatus
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.settings import settings
|
||||
@@ -154,7 +153,7 @@ async def api_get_payments(
|
||||
) -> Page[NodePayment] | None:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_payments(filters)
|
||||
@@ -167,7 +166,7 @@ async def api_get_invoices(
|
||||
) -> Page[NodeInvoice] | None:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_invoices(filters)
|
||||
|
||||
@@ -15,7 +15,10 @@ from lnbits import bolt11
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payment_count_stats,
|
||||
get_wallets_stats,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
@@ -32,14 +35,17 @@ from lnbits.core.models import (
|
||||
SettleInvoice,
|
||||
SimpleStatus,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.wallets import BaseWalletTypeInfo
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
check_account_id_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
require_base_admin_key,
|
||||
require_base_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
filter_dict_keys,
|
||||
@@ -64,7 +70,6 @@ from ..services import (
|
||||
perform_withdraw,
|
||||
settle_hold_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
)
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
@@ -79,10 +84,9 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(key_info.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=key_info.wallet.id,
|
||||
pending=True,
|
||||
@@ -98,11 +102,10 @@ async def api_payments(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(key_info.wallet.id)
|
||||
return await get_payments_history(key_info.wallet.id, group, filters)
|
||||
|
||||
|
||||
@@ -115,14 +118,14 @@ async def api_payments_history(
|
||||
async def api_payments_counting_stats(
|
||||
count_by: PaymentCountField = Query("tag"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
):
|
||||
if user.admin:
|
||||
if account_id.is_admin_id:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
for_user_id = account_id.id
|
||||
|
||||
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||
|
||||
@@ -135,14 +138,14 @@ async def api_payments_counting_stats(
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
):
|
||||
if user.admin:
|
||||
if account_id.is_admin_id:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
for_user_id = account_id.id
|
||||
|
||||
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||
|
||||
@@ -154,15 +157,15 @@ async def api_payments_wallets_stats(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_daily_stats(
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
if user.admin:
|
||||
if account_id.is_admin_id:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
for_user_id = account_id.id
|
||||
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@@ -175,18 +178,30 @@ async def api_payments_daily_stats(
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
recheck_pending: bool = Query(
|
||||
False, description="Force check and update of pending payments."
|
||||
),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=key_info.wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in page.data:
|
||||
if payment.pending:
|
||||
await update_pending_payment(payment)
|
||||
) -> Page[Payment]:
|
||||
async with db.connect() as conn:
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=key_info.wallet.id,
|
||||
filters=filters,
|
||||
conn=conn,
|
||||
)
|
||||
if not recheck_pending:
|
||||
return page
|
||||
|
||||
return page
|
||||
payments = []
|
||||
for payment in page.data:
|
||||
if payment.pending:
|
||||
refreshed_payment = await update_pending_payment(payment, conn=conn)
|
||||
payments.append(refreshed_payment)
|
||||
else:
|
||||
payments.append(payment)
|
||||
|
||||
return Page(data=payments, total=page.total)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
@@ -199,19 +214,19 @@ async def api_payments_paginated(
|
||||
)
|
||||
async def api_all_payments_paginated(
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
):
|
||||
if user.admin:
|
||||
if account_id.is_admin_id:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
for_user_id = account_id.id
|
||||
|
||||
return await get_payments_paginated(
|
||||
filters=filters,
|
||||
user_id=for_user_id,
|
||||
)
|
||||
async with db.connect() as conn:
|
||||
return await get_payments_paginated(
|
||||
filters=filters, user_id=for_user_id, conn=conn
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post(
|
||||
@@ -234,10 +249,10 @@ async def api_all_payments_paginated(
|
||||
)
|
||||
async def api_payments_create(
|
||||
invoice_data: CreateInvoice,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
|
||||
) -> Payment:
|
||||
wallet_id = wallet.wallet.id
|
||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||
wallet_id = key_info.wallet.id
|
||||
if invoice_data.out is True and key_info.key_type == KeyType.admin:
|
||||
if not invoice_data.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -247,6 +262,7 @@ async def api_payments_create(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
labels=invoice_data.labels,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -260,6 +276,26 @@ async def api_payments_create(
|
||||
return await create_payment_request(wallet_id, invoice_data)
|
||||
|
||||
|
||||
@payment_router.put("/{payment_hash}/labels")
|
||||
async def api_update_payment_labels(
|
||||
payment_hash: str,
|
||||
data: UpdatePaymentLabels,
|
||||
key_type: BaseWalletTypeInfo = Depends(require_base_admin_key),
|
||||
) -> SimpleStatus:
|
||||
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
|
||||
if payment is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
|
||||
account = await get_account(key_type.wallet.user)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
|
||||
|
||||
# only keep labels that belong to the user
|
||||
user_label_names = [label.name for label in account.extra.labels]
|
||||
payment.labels = [label for label in data.labels if label in user_label_names]
|
||||
await update_payment(payment)
|
||||
return SimpleStatus(success=True, message="Payment labels updated.")
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
@@ -301,7 +337,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||
return {"paid": False, "status": "failed"}
|
||||
|
||||
try:
|
||||
status = await payment.check_status()
|
||||
payment = await update_pending_payment(payment)
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
@@ -310,7 +346,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": payment.success,
|
||||
"status": f"{status!s}",
|
||||
"status": f"{payment.status!s}",
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ from fastapi import (
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from lnbits.core.models.wallets import BaseWalletTypeInfo
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
require_base_invoice_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -50,12 +51,12 @@ async def api_create_tinyurl(
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
tinyurl_id: str, key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
if tinyurl.wallet == key_info.wallet.id:
|
||||
return tinyurl
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
|
||||
@@ -115,12 +115,12 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
||||
|
||||
@users_router.put("/user/{user_id}", name="Update user")
|
||||
async def api_update_user(
|
||||
user_id: str, data: CreateUser, user: User = Depends(check_admin)
|
||||
user_id: str, data: CreateUser, account: Account = Depends(check_admin)
|
||||
) -> CreateUser:
|
||||
if user_id != data.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
|
||||
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
if user_id == settings.super_user and account.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
@@ -154,7 +154,7 @@ async def api_update_user(
|
||||
name="Delete user by Id",
|
||||
)
|
||||
async def api_users_delete_user(
|
||||
user_id: str, user: User = Depends(check_admin)
|
||||
user_id: str, account: Account = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
if len(wallets) > 0:
|
||||
@@ -169,7 +169,7 @@ async def api_users_delete_user(
|
||||
detail="Cannot delete super user.",
|
||||
)
|
||||
|
||||
if user_id in settings.lnbits_admin_users and not user.super_user:
|
||||
if user_id in settings.lnbits_admin_users and not account.is_super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Only super_user can delete admin user.",
|
||||
@@ -295,7 +295,7 @@ async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
|
||||
"The second time it is called will delete the entry from the DB",
|
||||
)
|
||||
async def api_users_delete_user_wallet(
|
||||
user_id: str, wallet: str, user: User = Depends(check_admin)
|
||||
user_id: str, wallet: str, account: Account = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
@@ -304,7 +304,7 @@ async def api_users_delete_user_wallet(
|
||||
detail="Wallet does not exist.",
|
||||
)
|
||||
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
if user_id == settings.super_user and account.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
|
||||
@@ -8,21 +8,38 @@ from fastapi import (
|
||||
HTTPException,
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
get_wallets_paginated,
|
||||
)
|
||||
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.models.wallets import (
|
||||
WalletsFilters,
|
||||
WalletSharePermission,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.core.services.wallets import (
|
||||
create_lightning_shared_wallet,
|
||||
delete_wallet_share,
|
||||
invite_to_wallet,
|
||||
reject_wallet_invitation,
|
||||
update_wallet_share_permissions,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_user_exists,
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_wallet,
|
||||
update_wallet,
|
||||
@@ -51,17 +68,46 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
||||
openapi_extra=generate_filter_params_openapi(WalletsFilters),
|
||||
)
|
||||
async def api_wallets_paginated(
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
filters: Filters = Depends(parse_filters(WalletsFilters)),
|
||||
):
|
||||
page = await get_wallets_paginated(
|
||||
user_id=user.id,
|
||||
user_id=account_id.id,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
@wallet_router.put("/share/invite")
|
||||
async def api_invite_wallet_share(
|
||||
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> WalletSharePermission:
|
||||
return await invite_to_wallet(key_info.wallet, data)
|
||||
|
||||
|
||||
@wallet_router.delete("/share/invite/{share_request_id}")
|
||||
async def api_reject_wallet_invitation(
|
||||
share_request_id: str, invited_user: Account = Depends(check_account_exists)
|
||||
) -> SimpleStatus:
|
||||
await reject_wallet_invitation(invited_user.id, share_request_id)
|
||||
return SimpleStatus(success=True, message="Invitation rejected.")
|
||||
|
||||
|
||||
@wallet_router.put("/share")
|
||||
async def api_accept_wallet_share_request(
|
||||
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> WalletSharePermission:
|
||||
return await update_wallet_share_permissions(key_info.wallet, data)
|
||||
|
||||
|
||||
@wallet_router.delete("/share/{share_request_id}")
|
||||
async def api_delete_wallet_share_permissions(
|
||||
share_request_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> SimpleStatus:
|
||||
return await delete_wallet_share(key_info.wallet, share_request_id)
|
||||
|
||||
|
||||
@wallet_router.put("/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -70,6 +116,7 @@ async def api_update_wallet_name(
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
wallet.name = new_name
|
||||
|
||||
await update_wallet(wallet)
|
||||
return {
|
||||
"id": wallet.id,
|
||||
@@ -80,12 +127,17 @@ async def api_update_wallet_name(
|
||||
|
||||
@wallet_router.put("/reset/{wallet_id}")
|
||||
async def api_reset_wallet_keys(
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
wallet_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
cache.pop(f"auth:wallet:{wallet.id}")
|
||||
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
|
||||
cache.pop(f"auth:x-api-key:{wallet.inkey}")
|
||||
|
||||
wallet.adminkey = uuid4().hex
|
||||
wallet.inkey = uuid4().hex
|
||||
await update_wallet(wallet)
|
||||
@@ -124,16 +176,17 @@ async def api_update_wallet(
|
||||
wallet.extra.color = color or wallet.extra.color
|
||||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
@wallet_router.delete("/{wallet_id}")
|
||||
async def api_delete_wallet(
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
wallet_id: str, account_id: AccountId = Depends(check_account_id_exists)
|
||||
) -> None:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
await delete_wallet(
|
||||
@@ -144,7 +197,25 @@ async def api_delete_wallet(
|
||||
|
||||
@wallet_router.post("")
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
data: CreateWallet, account_id: AccountId = Depends(check_account_id_exists)
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
|
||||
|
||||
if data.wallet_type not in list(WalletType):
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Wallet type {data.wallet_type} does not exist.",
|
||||
)
|
||||
|
||||
if data.wallet_type == WalletType.LIGHTNING_SHARED:
|
||||
if not data.shared_wallet_id:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Shared wallet ID is required for shared wallets.",
|
||||
)
|
||||
return await create_lightning_shared_wallet(
|
||||
user_id=account_id.id,
|
||||
source_wallet_id=data.shared_wallet_id,
|
||||
)
|
||||
|
||||
# default WalletType.LIGHTNING:
|
||||
return await create_wallet(user_id=account_id.id, wallet_name=data.name)
|
||||
|
||||
@@ -15,9 +15,9 @@ from lnbits.core.models import (
|
||||
CreateWebPushSubscription,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.decorators import (
|
||||
check_user_exists,
|
||||
check_account_id_exists,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> WebPushSubscription:
|
||||
try:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, user.id)
|
||||
subscription = await get_webpush_subscription(endpoint, account_id.id)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
user.id,
|
||||
account_id.id,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
@@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
):
|
||||
try:
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
count = await delete_webpush_subscription(endpoint, user.id)
|
||||
count = await delete_webpush_subscription(endpoint, account_id.id)
|
||||
return {"count": count}
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
+92
-25
@@ -130,6 +130,12 @@ class Compat:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
@property
|
||||
def blob(self) -> str:
|
||||
if self.type in {POSTGRES}:
|
||||
return "BYTEA"
|
||||
return "BLOB"
|
||||
|
||||
def timestamp_placeholder(self, key: str) -> str:
|
||||
return compat_timestamp_placeholder(key)
|
||||
|
||||
@@ -216,18 +222,34 @@ class Connection(Compat):
|
||||
filters: Filters | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
group_by: list[str] | None = None,
|
||||
table_name: str | None = None,
|
||||
) -> Page[TModel]:
|
||||
"""
|
||||
Parameters:
|
||||
query: The main SQL query string to execute for data retrieval.
|
||||
where: list of additional WHERE clause conditions to filter results.
|
||||
values: dictionary of parameter values to be used in the SQL query.
|
||||
filters: object for advanced filtering, sorting, and pagination logic.
|
||||
model: pydantic model type to map query results into model instances.
|
||||
group_by: list of column names to group results by in the SQL query.
|
||||
table_name: if provided some optimisations can be applied.
|
||||
"""
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
if table_name:
|
||||
if not _valid_sql_name(table_name):
|
||||
raise ValueError(f"Invalid table name: '{table_name}'.")
|
||||
filters.set_table_name(table_name)
|
||||
|
||||
clause = filters.where(where)
|
||||
parsed_values = filters.values(values)
|
||||
|
||||
group_by_string = ""
|
||||
if group_by:
|
||||
for field in group_by:
|
||||
if not re.fullmatch(
|
||||
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
|
||||
):
|
||||
if not _valid_sql_name(field):
|
||||
raise ValueError("Value for GROUP BY is invalid")
|
||||
group_by_string = f"GROUP BY {', '.join(group_by)}"
|
||||
|
||||
@@ -245,16 +267,17 @@ class Connection(Compat):
|
||||
if rows:
|
||||
# no need for extra query if no pagination is specified
|
||||
if filters.offset or filters.limit:
|
||||
result = await self.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) as count FROM (
|
||||
if table_name:
|
||||
count_query = f"SELECT COUNT(*) as count FROM {table_name} {clause}" # noqa: S608
|
||||
else:
|
||||
count_query = f"""SELECT COUNT(*) as count
|
||||
FROM (
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""", # noqa: S608
|
||||
parsed_values,
|
||||
)
|
||||
) as count""" # noqa: S608
|
||||
|
||||
result = await self.execute(count_query, parsed_values)
|
||||
row = result.mappings().first()
|
||||
result.close()
|
||||
count = int(row.get("count", 0))
|
||||
@@ -387,9 +410,12 @@ class Database(Compat):
|
||||
filters: Filters | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
group_by: list[str] | None = None,
|
||||
table_name: str | None = None,
|
||||
) -> Page[TModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||
return await conn.fetch_page(
|
||||
query, where, values, filters, model, group_by, table_name
|
||||
)
|
||||
|
||||
async def execute(self, query: str, values: dict | None = None):
|
||||
async with self.connect() as conn:
|
||||
@@ -425,6 +451,8 @@ class Operator(Enum):
|
||||
INCLUDE = "in"
|
||||
EXCLUDE = "ex"
|
||||
LIKE = "like"
|
||||
EVERY = "every"
|
||||
ANY = "any"
|
||||
|
||||
@property
|
||||
def as_sql(self):
|
||||
@@ -444,7 +472,7 @@ class Operator(Enum):
|
||||
return ">="
|
||||
elif self == Operator.LE:
|
||||
return "<="
|
||||
elif self == Operator.LIKE:
|
||||
elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
|
||||
return "LIKE"
|
||||
else:
|
||||
raise ValueError("Unknown SQL Operator")
|
||||
@@ -466,6 +494,7 @@ class Page(BaseModel, Generic[T]):
|
||||
|
||||
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
table_name: str | None = None
|
||||
field: str
|
||||
op: Operator = Operator.EQ
|
||||
model: type[TFilterModel] | None
|
||||
@@ -475,6 +504,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
def parse_query(
|
||||
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
|
||||
):
|
||||
if i > 1000 or len(raw_values) > 1000:
|
||||
raise ValueError("Too many filter values")
|
||||
# Key format:
|
||||
# key[operator]
|
||||
# e.g. name[eq]
|
||||
@@ -491,11 +522,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values: dict = {}
|
||||
for raw_value in raw_values:
|
||||
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
raw_values = [v for rv in raw_values for v in rv.split(",")]
|
||||
|
||||
for index, raw_value in enumerate(raw_values):
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
values[f"{field}__{i}"] = validated
|
||||
values[f"{field}__{index}"] = validated
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
@@ -503,15 +537,24 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
@property
|
||||
def statement(self) -> str:
|
||||
prefix = f"{self.table_name}." if self.table_name else ""
|
||||
stmt = []
|
||||
for key in self.values.keys() if self.values else []:
|
||||
clean_key = key.split("__")[0]
|
||||
if self.model and self.model.__fields__[clean_key].type_ == datetime:
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
||||
placeholder = compat_timestamp_placeholder(key)
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(f":{key}")
|
||||
else:
|
||||
placeholder = f":{key}"
|
||||
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
|
||||
return " OR ".join(stmt)
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
|
||||
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
||||
elif self.op == Operator.EVERY:
|
||||
statement = " AND ".join(stmt)
|
||||
else:
|
||||
statement = " OR ".join(stmt)
|
||||
return f"({statement})"
|
||||
|
||||
|
||||
class Filters(BaseModel, Generic[TFilterModel]):
|
||||
@@ -527,13 +570,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
search: str | None = None
|
||||
|
||||
offset: int | None = None
|
||||
limit: int | None = None
|
||||
|
||||
limit: int | None = 10
|
||||
sortby: str | None = None
|
||||
direction: Literal["asc", "desc"] | None = None
|
||||
|
||||
model: type[TFilterModel] | None = None
|
||||
|
||||
table_name: str | None = None
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_sortby(cls, values):
|
||||
sortby = values.get("sortby")
|
||||
@@ -548,8 +592,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def pagination(self) -> str:
|
||||
stmt = ""
|
||||
if self.limit:
|
||||
stmt += f"LIMIT {self.limit} "
|
||||
self.limit = self.limit or 10
|
||||
stmt += f"LIMIT {min(1000, self.limit)} "
|
||||
if self.offset:
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
return stmt
|
||||
@@ -575,7 +619,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
def order_by(self) -> str:
|
||||
if self.sortby:
|
||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||
prefix = f"{self.table_name}." if self.table_name else ""
|
||||
return f"ORDER BY {prefix}{self.sortby} {self.direction or 'asc'}"
|
||||
return ""
|
||||
|
||||
def values(self, values: dict | None = None) -> dict:
|
||||
@@ -587,12 +632,26 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for key, value in page_filter.values.items():
|
||||
if page_filter.op == Operator.LIKE:
|
||||
values[key] = f"%{value}%"
|
||||
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
|
||||
values[key] = f"""%"{value}"%"""
|
||||
else:
|
||||
values[key] = value
|
||||
if self.search and self.model:
|
||||
values["search"] = f"%{self.search.lower()}%"
|
||||
return values
|
||||
|
||||
def set_table_name(self, table_name: str) -> None:
|
||||
self.table_name = table_name
|
||||
for page_filter in self.filters:
|
||||
page_filter.table_name = table_name
|
||||
|
||||
|
||||
class DbJsonEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Enum):
|
||||
return o.value
|
||||
return super().default(o)
|
||||
|
||||
|
||||
def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
"""
|
||||
@@ -648,7 +707,7 @@ def model_to_dict(model: BaseModel) -> dict:
|
||||
or type_ is dict
|
||||
or get_origin(outertype_) is list
|
||||
):
|
||||
_dict[key] = json.dumps(value)
|
||||
_dict[key] = json.dumps(value, cls=DbJsonEncoder)
|
||||
continue
|
||||
_dict[key] = value
|
||||
|
||||
@@ -722,3 +781,11 @@ def _safe_load_json(value: str) -> dict:
|
||||
# DB is corrupted if it gets here
|
||||
logger.error(f"Failed to decode JSON: '{value}'")
|
||||
return {}
|
||||
|
||||
|
||||
def _valid_sql_name(name: str) -> bool:
|
||||
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
|
||||
return (
|
||||
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", name)
|
||||
is not None
|
||||
)
|
||||
|
||||
+246
-53
@@ -19,6 +19,8 @@ from lnbits.core.crud import (
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user_access_control_lists
|
||||
from lnbits.core.crud.wallets import get_base_wallet_for_key
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
AccessTokenPayload,
|
||||
Account,
|
||||
@@ -27,9 +29,12 @@ from lnbits.core.models import (
|
||||
User,
|
||||
WalletTypeInfo,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.wallets import BaseWallet, BaseWalletTypeInfo
|
||||
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||
from lnbits.helpers import normalize_path, path_segments
|
||||
from lnbits.helpers import normalize_path, path_segments, sha256s
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="api/v1/auth",
|
||||
@@ -52,7 +57,7 @@ api_key_query = APIKeyQuery(
|
||||
)
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
class BaseKeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
@@ -77,8 +82,7 @@ class KeyChecker(SecurityBase):
|
||||
)
|
||||
self.model: APIKey = openapi_model # type: ignore
|
||||
|
||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||
|
||||
def _extract_key_value(self, request):
|
||||
key_value = (
|
||||
self._api_key
|
||||
if self._api_key
|
||||
@@ -91,27 +95,88 @@ class KeyChecker(SecurityBase):
|
||||
detail="No Api Key provided.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet_for_key(key_value)
|
||||
return key_value
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
request.scope["user_id"] = wallet.user
|
||||
async def _extract_key_type(self, key_value: str, wallet: BaseWallet) -> KeyType:
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
await _check_user_extension_access(wallet.user, request["path"])
|
||||
|
||||
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
|
||||
return key_type
|
||||
|
||||
|
||||
class KeyChecker(BaseKeyChecker):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
expected_key_type: KeyType | None = None,
|
||||
):
|
||||
super().__init__(api_key, expected_key_type)
|
||||
|
||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||
key_value = self._extract_key_value(request)
|
||||
|
||||
async with db.connect() as conn:
|
||||
wallet = await get_wallet_for_key(key_value, conn=conn)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
request.scope["user_id"] = wallet.user
|
||||
await _check_user_access(request, wallet.user, conn=conn)
|
||||
|
||||
key_type = await self._extract_key_type(key_value, wallet)
|
||||
return WalletTypeInfo(key_type, wallet)
|
||||
|
||||
|
||||
class LightKeyChecker(BaseKeyChecker):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
expected_key_type: KeyType | None = None,
|
||||
):
|
||||
super().__init__(api_key, expected_key_type)
|
||||
|
||||
async def __call__(self, request: Request) -> BaseWalletTypeInfo:
|
||||
key_value = self._extract_key_value(request)
|
||||
cache_key = f"auth:x-api-key:{key_value}"
|
||||
cache_time = settings.auth_authentication_cache_minutes * 60
|
||||
|
||||
async with db.connect() as conn:
|
||||
if cache_time > 0:
|
||||
key_info: BaseWalletTypeInfo | None = cache.get(cache_key)
|
||||
if key_info:
|
||||
request.scope["user_id"] = key_info.wallet.user
|
||||
await _check_user_access(request, key_info.wallet.user, conn=conn)
|
||||
return key_info
|
||||
|
||||
wallet = await get_base_wallet_for_key(key_value, conn=conn)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
request.scope["user_id"] = wallet.user
|
||||
await _check_user_access(request, wallet.user, conn=conn)
|
||||
|
||||
key_type = await self._extract_key_type(key_value, wallet)
|
||||
key_info = BaseWalletTypeInfo(key_type, wallet)
|
||||
|
||||
if cache_time > 0:
|
||||
cache.set(cache_key, key_info, expiry=cache_time)
|
||||
cache.set(f"auth:wallet:{wallet.id}", wallet, expiry=cache_time)
|
||||
return key_info
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
@@ -124,6 +189,18 @@ async def require_admin_key(
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_base_admin_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> BaseWalletTypeInfo:
|
||||
check: LightKeyChecker = LightKeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.admin,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_invoice_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
@@ -136,6 +213,18 @@ async def require_invoice_key(
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_base_invoice_key(
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> BaseWalletTypeInfo:
|
||||
check: LightKeyChecker = LightKeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.invoice,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def check_access_token(
|
||||
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
|
||||
cookie_access_token: Annotated[str | None, Cookie()] = None,
|
||||
@@ -144,33 +233,95 @@ async def check_access_token(
|
||||
return header_access_token or cookie_access_token or bearer_access_token
|
||||
|
||||
|
||||
async def check_account_id_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> AccountId:
|
||||
cache_key: str | None = None
|
||||
if access_token:
|
||||
cache_key = f"auth:access_token:{sha256s(access_token)}"
|
||||
elif usr:
|
||||
cache_key = f"auth:user_id:{sha256s(usr.hex)}"
|
||||
|
||||
async with db.connect() as conn:
|
||||
if cache_key and settings.auth_authentication_cache_minutes > 0:
|
||||
account_id = cache.get(cache_key)
|
||||
if account_id:
|
||||
r.scope["user_id"] = account_id.id
|
||||
await _check_user_access(r, account_id.id, conn=conn)
|
||||
return account_id
|
||||
|
||||
account = await _check_account_exists(r, access_token, usr, conn=conn)
|
||||
account_id = AccountId(id=account.id)
|
||||
|
||||
if cache_key and settings.auth_authentication_cache_minutes > 0:
|
||||
cache.set(
|
||||
cache_key,
|
||||
account_id,
|
||||
expiry=settings.auth_authentication_cache_minutes * 60,
|
||||
)
|
||||
|
||||
return account_id
|
||||
|
||||
|
||||
async def check_account_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> Account:
|
||||
return await _check_account_exists(r, access_token, usr)
|
||||
|
||||
|
||||
async def _check_account_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Account:
|
||||
"""
|
||||
Check that the account exists based on access token or user id.
|
||||
More performant version of `check_user_exists`.
|
||||
Unlike `check_user_exists`, this function:
|
||||
- does not fetch the user wallets
|
||||
- caches the account info based on settings cache time
|
||||
"""
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(
|
||||
access_token, r["path"], r["method"], conn=new_conn
|
||||
)
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex, conn=new_conn)
|
||||
if account and account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Missing user ID or access token."
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
await _check_user_access(r, account.id, conn=new_conn)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
async def check_user_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> User:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex)
|
||||
if account and account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
if not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
async with db.connect() as conn:
|
||||
account = await _check_account_exists(r, access_token, usr, conn=conn)
|
||||
user = await get_user_from_account(account, conn=conn)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||
await _check_user_extension_access(user.id, r["path"])
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -198,29 +349,36 @@ async def access_token_payload(
|
||||
return AccessTokenPayload(**payload)
|
||||
|
||||
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
async def check_admin(
|
||||
account: Annotated[Account, Depends(check_account_exists)],
|
||||
) -> Account:
|
||||
if (
|
||||
account.id != settings.super_user
|
||||
and account.id not in settings.lnbits_admin_users
|
||||
):
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
if not account.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
|
||||
)
|
||||
|
||||
return user
|
||||
return account
|
||||
|
||||
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
async def check_super_user(
|
||||
account: Annotated[Account, Depends(check_admin)],
|
||||
) -> Account:
|
||||
if account.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
if not account.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
|
||||
)
|
||||
return user
|
||||
return account
|
||||
|
||||
|
||||
def parse_filters(model: type[TFilterModel]):
|
||||
@@ -280,9 +438,17 @@ async def check_user_extension_access(
|
||||
return SimpleStatus(success=True, message="OK")
|
||||
|
||||
|
||||
async def _check_user_extension_access(user_id: str, path: str):
|
||||
async def _check_user_access(r: Request, user_id: str, conn: Connection | None = None):
|
||||
if not settings.is_user_allowed(user_id):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
await _check_user_extension_access(user_id, r["path"], conn=conn)
|
||||
|
||||
|
||||
async def _check_user_extension_access(
|
||||
user_id: str, path: str, conn: Connection | None = None
|
||||
):
|
||||
ext_id = path_segments(path)[0]
|
||||
status = await check_user_extension_access(user_id, ext_id)
|
||||
status = await check_user_extension_access(user_id, ext_id, conn=conn)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
@@ -291,12 +457,12 @@ async def _check_user_extension_access(user_id: str, path: str):
|
||||
|
||||
|
||||
async def _get_account_from_token(
|
||||
access_token: str, path: str, method: str
|
||||
access_token: str, path: str, method: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
try:
|
||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||
return await _get_account_from_jwt_payload(
|
||||
AccessTokenPayload(**payload), path, method
|
||||
AccessTokenPayload(**payload), path, method, conn=conn
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
@@ -309,33 +475,35 @@ async def _get_account_from_token(
|
||||
|
||||
|
||||
async def _get_account_from_jwt_payload(
|
||||
payload: AccessTokenPayload, path: str, method: str
|
||||
payload: AccessTokenPayload, path: str, method: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
account = None
|
||||
if payload.sub:
|
||||
account = await get_account_by_username(payload.sub)
|
||||
account = await get_account_by_username(payload.sub, conn=conn)
|
||||
elif payload.usr:
|
||||
account = await get_account(payload.usr)
|
||||
account = await get_account(payload.usr, conn=conn)
|
||||
elif payload.email:
|
||||
account = await get_account_by_email(payload.email)
|
||||
account = await get_account_by_email(payload.email, conn=conn)
|
||||
|
||||
if not account:
|
||||
return None
|
||||
|
||||
if payload.api_token_id:
|
||||
await _check_account_api_access(account.id, payload.api_token_id, path, method)
|
||||
await _check_account_api_access(
|
||||
account.id, payload.api_token_id, path, method, conn=conn
|
||||
)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
async def _check_account_api_access(
|
||||
user_id: str, token_id: str, path: str, method: str
|
||||
user_id: str, token_id: str, path: str, method: str, conn: Connection | None = None
|
||||
):
|
||||
segments = path.split("/")
|
||||
if len(segments) < 3:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
|
||||
|
||||
acls = await get_user_access_control_lists(user_id)
|
||||
acls = await get_user_access_control_lists(user_id, conn=conn)
|
||||
acl = acls.get_acl_by_token_id(token_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
|
||||
@@ -359,3 +527,28 @@ def url_for_interceptor(original_method):
|
||||
# Upgraded extensions modify the path.
|
||||
# This interceptor ensures that the path is normalized.
|
||||
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
|
||||
|
||||
|
||||
async def check_admin_ui() -> None:
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Admin UI is disabled."
|
||||
)
|
||||
|
||||
|
||||
async def check_extension_builder(
|
||||
user: Annotated[User, Depends(check_user_exists)],
|
||||
) -> None:
|
||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Extension Builder is disabled for non admin users.",
|
||||
)
|
||||
|
||||
|
||||
async def check_first_install():
|
||||
if not settings.first_install:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Super user account has already been configured.",
|
||||
)
|
||||
|
||||
@@ -138,7 +138,9 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def error_handler_404(request: Request, exc: HTTPException):
|
||||
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
|
||||
|
||||
if not request.url.path.endswith("routes.json"):
|
||||
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
|
||||
|
||||
if not _is_browser_request(request):
|
||||
return JSONResponse(
|
||||
|
||||
@@ -8,6 +8,7 @@ from loguru import logger
|
||||
from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .paypal import PayPalWallet
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
@@ -15,6 +16,7 @@ fiat_module = importlib.import_module("lnbits.fiat")
|
||||
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
paypal = "PayPalWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider | None:
|
||||
@@ -49,5 +51,6 @@ fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PayPalWallet",
|
||||
"StripeWallet",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
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 PayPalCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
cancel_url: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayPalCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checkout: PayPalCheckoutOptions | None = None
|
||||
|
||||
|
||||
class PayPalWallet(FiatProvider):
|
||||
"""https://developer.paypal.com/api/rest/"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing PayPalWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.paypal_api_endpoint:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing endpoint.")
|
||||
if not settings.paypal_client_id:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing client id.")
|
||||
if not settings.paypal_client_secret:
|
||||
raise ValueError("Cannot initialize PayPalWallet: missing client secret.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.paypal_api_endpoint)
|
||||
self.headers = {
|
||||
"User-Agent": f"PayPal Alan:{settings.version}",
|
||||
}
|
||||
self._access_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("PayPalWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing PayPal 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:
|
||||
await self._ensure_access_token()
|
||||
return FiatStatusResponse(balance=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 PayPal options")
|
||||
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Unable to authenticate."
|
||||
)
|
||||
|
||||
co = opts.checkout or PayPalCheckoutOptions()
|
||||
success_url = (
|
||||
co.success_url
|
||||
or settings.paypal_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
cancel_url = co.cancel_url or success_url
|
||||
|
||||
order_data = {
|
||||
"intent": "CAPTURE",
|
||||
"purchase_units": [
|
||||
{
|
||||
"amount": {
|
||||
"currency_code": currency.upper(),
|
||||
"value": f"{amount:.2f}",
|
||||
},
|
||||
"custom_id": payment_hash[:127], # PayPal limit
|
||||
"invoice_id": payment_hash[:127],
|
||||
"description": memo or "LNbits Invoice",
|
||||
}
|
||||
],
|
||||
"application_context": {
|
||||
"return_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"shipping_preference": "NO_SHIPPING",
|
||||
"user_action": "PAY_NOW",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v2/checkout/orders", json=order_data, headers=self._auth_headers()
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
order_id = data.get("id")
|
||||
approval_url = self._get_approval_url(data.get("links") or [])
|
||||
if not order_id or not approval_url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing id or approval url"
|
||||
)
|
||||
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"fiat_paypal_{order_id}",
|
||||
payment_request=approval_url,
|
||||
)
|
||||
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:
|
||||
success_url = (
|
||||
payment_options.success_url
|
||||
or settings.paypal_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:
|
||||
await self._ensure_access_token()
|
||||
payload = {
|
||||
"plan_id": subscription_id,
|
||||
"quantity": str(quantity),
|
||||
"custom_id": self._serialize_metadata(payment_options),
|
||||
"application_context": {
|
||||
"return_url": success_url,
|
||||
"cancel_url": success_url,
|
||||
},
|
||||
}
|
||||
r = await self.client.post(
|
||||
"/v1/billing/subscriptions",
|
||||
json=payload,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
approval_url = self._get_approval_url(data.get("links") or [])
|
||||
if not approval_url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing approval url"
|
||||
)
|
||||
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=approval_url,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
)
|
||||
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:
|
||||
await self._ensure_access_token()
|
||||
r = await self.client.post(
|
||||
f"/v1/billing/subscriptions/{subscription_id}/cancel",
|
||||
json={"reason": f"Cancelled by {correlation_id}"},
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
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("PayPal does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
await self._ensure_access_token()
|
||||
order_id = self._normalize_paypal_id(checking_id)
|
||||
r = await self.client.get(
|
||||
f"/v2/checkout/orders/{order_id}", headers=self._auth_headers()
|
||||
)
|
||||
r.raise_for_status()
|
||||
return self._status_from_order(r.json())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting PayPal order status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("PayPal does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"PayPal 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 _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (order.get("status") or "").upper()
|
||||
if status in ["COMPLETED", "APPROVED"]:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
def _normalize_paypal_id(self, checking_id: str) -> str:
|
||||
return (
|
||||
checking_id.replace("fiat_paypal_", "", 1)
|
||||
if checking_id.startswith("fiat_paypal_")
|
||||
else checking_id
|
||||
)
|
||||
|
||||
def _serialize_metadata(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> str:
|
||||
md = {
|
||||
"wallet_id": payment_options.wallet_id,
|
||||
"memo": payment_options.memo,
|
||||
"extra": payment_options.extra,
|
||||
"tag": payment_options.tag,
|
||||
"subscription_request_id": payment_options.subscription_request_id,
|
||||
}
|
||||
raw = json.dumps(md)
|
||||
return raw[:127] # PayPal custom_id limit
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> PayPalCreateInvoiceOptions | None:
|
||||
try:
|
||||
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid PayPal options: {e}")
|
||||
return None
|
||||
|
||||
async def _ensure_access_token(self):
|
||||
if self._access_token and time.time() < self._token_expires_at:
|
||||
return
|
||||
|
||||
r = await self.client.post(
|
||||
"/v1/oauth2/token",
|
||||
data={"grant_type": "client_credentials"},
|
||||
auth=(settings.paypal_client_id or "", settings.paypal_client_secret or ""),
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
token = data.get("access_token")
|
||||
expires_in = int(data.get("expires_in") or 300)
|
||||
if not token:
|
||||
raise ValueError("Unable to retrieve PayPal access token.")
|
||||
self._access_token = token
|
||||
self._token_expires_at = time.time() + expires_in - 30
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {**self.headers, "Authorization": f"Bearer {self._access_token}"}
|
||||
|
||||
def _get_approval_url(self, links: list[dict[str, Any]]) -> str | None:
|
||||
for link in links:
|
||||
if link.get("rel") == "approve":
|
||||
return link.get("href")
|
||||
return None
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.paypal_api_endpoint),
|
||||
str(settings.paypal_client_id),
|
||||
str(settings.paypal_client_secret),
|
||||
str(settings.paypal_webhook_id),
|
||||
]
|
||||
)
|
||||
+23
-2
@@ -35,6 +35,7 @@ class StripeTerminalOptions(BaseModel):
|
||||
|
||||
capture_method: Literal["automatic", "manual"] = "automatic"
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
reader_id: str | None = None
|
||||
|
||||
|
||||
class StripeCheckoutOptions(BaseModel):
|
||||
@@ -170,7 +171,7 @@ class StripeWallet(FiatProvider):
|
||||
("line_items[0][price]", subscription_id),
|
||||
("line_items[0][quantity]", f"{quantity}"),
|
||||
]
|
||||
subscription_data = {**payment_options.dict(), "lnbits_action": "subscription"}
|
||||
subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
|
||||
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
|
||||
|
||||
form_data += self._encode_metadata(
|
||||
@@ -295,6 +296,15 @@ class StripeWallet(FiatProvider):
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def _process_terminal_payment_intent(
|
||||
self, reader_id: str, payment_intent_id: str
|
||||
) -> None:
|
||||
data = {"payment_intent": payment_intent_id}
|
||||
r = await self.client.post(
|
||||
f"/v1/terminal/readers/{reader_id}/process_payment_intent", data=data
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def _create_checkout_invoice(
|
||||
self,
|
||||
amount_cents: int,
|
||||
@@ -315,7 +325,7 @@ class StripeWallet(FiatProvider):
|
||||
("mode", "payment"),
|
||||
("success_url", success_url),
|
||||
("metadata[payment_hash]", payment_hash),
|
||||
("metadata[lnbits_action]", "invoice"),
|
||||
("metadata[alan_action]", "invoice"),
|
||||
("line_items[0][price_data][currency]", currency.lower()),
|
||||
("line_items[0][price_data][product_data][name]", line_item_name),
|
||||
("line_items[0][price_data][unit_amount]", str(amount_cents)),
|
||||
@@ -378,6 +388,17 @@ class StripeWallet(FiatProvider):
|
||||
ok=False,
|
||||
error_message="Error: missing PaymentIntent or client_secret",
|
||||
)
|
||||
if term.reader_id:
|
||||
try:
|
||||
await self._process_terminal_payment_intent(term.reader_id, pi_id)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False,
|
||||
error_message=(
|
||||
"Error: unable to process PaymentIntent on reader"
|
||||
),
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True, checking_id=pi_id, payment_request=client_secret
|
||||
)
|
||||
|
||||
+28
-17
@@ -18,6 +18,7 @@ from pydantic.schema import field_schema
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from .db import FilterModel
|
||||
|
||||
@@ -67,9 +68,10 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
t.env.globals["normalize_path"] = normalize_path
|
||||
|
||||
window_settings = {
|
||||
"AD_SPACE": settings.lnbits_ad_space.split(","),
|
||||
"AD_SPACE": settings.lnbits_ad_space,
|
||||
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
|
||||
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
|
||||
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
|
||||
@@ -90,6 +92,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
|
||||
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
|
||||
"LNBITS_APPLE_TOUCH_ICON": settings.lnbits_apple_touch_icon,
|
||||
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
|
||||
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
|
||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||
@@ -97,15 +100,21 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||
"LNBITS_VERSION": settings.version,
|
||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
"LNBITS_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||
"LNBITS_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||
"LNBITS_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||
"LNBITS_DENOMINATION": settings.lnbits_denomination,
|
||||
"has_holdinvoice": settings.has_holdinvoice,
|
||||
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
|
||||
"LNBITS_TELEGRAM_CONFIGURED": settings.is_telegram_notifications_configured(),
|
||||
"LNBITS_EXT_BUILDER": settings.lnbits_extensions_builder_activate_non_admins,
|
||||
"LNBITS_CURRENCIES": list(currencies.keys()),
|
||||
"LNBITS_ALLOWED_CURRENCIES": settings.lnbits_allowed_currencies,
|
||||
"CACHE_KEY": settings.server_startup_time,
|
||||
}
|
||||
|
||||
t.env.globals["WINDOW_SETTINGS"] = window_settings
|
||||
@@ -198,6 +207,11 @@ def is_valid_username(username: str) -> bool:
|
||||
return re.fullmatch(username_regex, username) is not None
|
||||
|
||||
|
||||
def is_valid_label(label: str) -> bool:
|
||||
label_regex = r"([A-Za-z0-9 ._-]{1,100}$)"
|
||||
return re.fullmatch(label_regex, label) is not None
|
||||
|
||||
|
||||
def is_valid_external_id(external_id: str) -> bool:
|
||||
if len(external_id) > 256:
|
||||
return False
|
||||
@@ -353,17 +367,6 @@ def normalize_path(path: str | None) -> str:
|
||||
return "/" + "/".join(path_segments(path))
|
||||
|
||||
|
||||
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
|
||||
image_folder = Path(settings.lnbits_data_folder, directory)
|
||||
file_path = image_folder / filename
|
||||
# Prevent dir traversal attack
|
||||
if image_folder.resolve() not in file_path.resolve().parents:
|
||||
raise ValueError("Unsafe filename.")
|
||||
# Prevent filename with subdirectories
|
||||
file_path = image_folder / filename.split("/")[-1]
|
||||
return file_path.resolve()
|
||||
|
||||
|
||||
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
@@ -397,3 +400,11 @@ def is_snake_case(v: str) -> bool:
|
||||
|
||||
def lowercase_first_letter(s: str) -> str:
|
||||
return s[:1].lower() + s[1:] if s else s
|
||||
|
||||
|
||||
def sha256s(value: str) -> str:
|
||||
"""
|
||||
SHA256 applied on a string value.
|
||||
Returns the hex as a string.
|
||||
"""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
+44
-7
@@ -270,6 +270,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: str | None = Field(default=None)
|
||||
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
|
||||
lnbits_apple_touch_icon: str | None = Field(default=None)
|
||||
lnbits_default_reaction: str = Field(default="confettiBothSides")
|
||||
lnbits_default_theme: str = Field(default="salvador")
|
||||
lnbits_default_border: str = Field(default="hard-border")
|
||||
@@ -280,8 +281,11 @@ class ThemesSettings(LNbitsSettings):
|
||||
class OpsSettings(LNbitsSettings):
|
||||
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
|
||||
lnbits_upload_allowed_types: list[str] = Field(
|
||||
|
||||
|
||||
class AssetSettings(LNbitsSettings):
|
||||
lnbits_max_asset_size_mb: float = Field(default=2.5, ge=0.0)
|
||||
lnbits_assets_allowed_mime_types: list[str] = Field(
|
||||
default=[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
@@ -297,6 +301,18 @@ class OpsSettings(LNbitsSettings):
|
||||
"heics",
|
||||
]
|
||||
)
|
||||
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
|
||||
lnbits_asset_thumbnail_height: int = Field(default=128, ge=0)
|
||||
lnbits_asset_thumbnail_format: str = Field(default="png")
|
||||
|
||||
lnbits_max_assets_per_user: int = Field(default=1, ge=0)
|
||||
lnbits_assets_no_limit_users: list[str] = Field(default=[])
|
||||
|
||||
def is_unlimited_assets_user(self, user_id: str) -> bool:
|
||||
return (
|
||||
settings.is_admin_user(user_id)
|
||||
or user_id in self.lnbits_assets_no_limit_users
|
||||
)
|
||||
|
||||
|
||||
class FeeSettings(LNbitsSettings):
|
||||
@@ -429,7 +445,7 @@ class NotificationsSettings(LNbitsSettings):
|
||||
|
||||
lnbits_notification_settings_update: bool = Field(default=True)
|
||||
lnbits_notification_credit_debit: bool = Field(default=True)
|
||||
notification_balance_delta_changed: bool = Field(default=True)
|
||||
notification_balance_delta_threshold_sats: int = Field(default=1, ge=0)
|
||||
lnbits_notification_server_start_stop: bool = Field(default=True)
|
||||
lnbits_notification_watchdog: bool = Field(default=False)
|
||||
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
|
||||
@@ -591,7 +607,6 @@ class BreezLiquidSdkFundingSource(LNbitsSettings):
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: str | None = Field(default=None)
|
||||
boltz_client_wallet: str | None = Field(default="lnbits")
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
@@ -632,6 +647,20 @@ class StripeFiatProvider(LNbitsSettings):
|
||||
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class PayPalFiatProvider(LNbitsSettings):
|
||||
paypal_enabled: bool = Field(default=False)
|
||||
paypal_api_endpoint: str = Field(default="https://api-m.paypal.com")
|
||||
paypal_client_id: str | None = Field(default=None)
|
||||
paypal_client_secret: str | None = Field(default=None)
|
||||
paypal_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
paypal_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/paypal"
|
||||
)
|
||||
paypal_webhook_id: str | None = Field(default=None)
|
||||
|
||||
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -667,7 +696,7 @@ class FundingSourcesSettings(
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
class FiatProvidersSettings(StripeFiatProvider):
|
||||
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
@@ -676,7 +705,8 @@ class FiatProvidersSettings(StripeFiatProvider):
|
||||
return False
|
||||
if provider == "stripe":
|
||||
return self.stripe_enabled
|
||||
# Add checks for other fiat providers here as needed
|
||||
if provider == "paypal":
|
||||
return self.paypal_enabled
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
@@ -690,7 +720,12 @@ class FiatProvidersSettings(StripeFiatProvider):
|
||||
):
|
||||
allowed_providers.append("stripe")
|
||||
|
||||
# Add other fiat providers here as needed
|
||||
if self.paypal_enabled and (
|
||||
not self.paypal_limits.allowed_users
|
||||
or user_id in self.paypal_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("paypal")
|
||||
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
@@ -747,6 +782,7 @@ class AuthSettings(LNbitsSettings):
|
||||
# How many seconds after login the user is allowed to update its credentials.
|
||||
# A fresh login is required afterwards.
|
||||
auth_credetials_update_threshold: int = Field(default=120, gt=0)
|
||||
auth_authentication_cache_minutes: int = Field(default=10, ge=0)
|
||||
|
||||
def is_auth_method_allowed(self, method: AuthMethods):
|
||||
return method.value in self.auth_allowed_methods
|
||||
@@ -867,6 +903,7 @@ class EditableSettings(
|
||||
ExtensionsSettings,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
AssetSettings,
|
||||
FeeSettings,
|
||||
ExchangeProvidersSettings,
|
||||
SecuritySettings,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17
-17
File diff suppressed because one or more lines are too long
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div class="error-code" v-text="code"></div>
|
||||
<div class="error-message" v-text="message"></div>
|
||||
<div class="q-mx-auto q-mt-lg justify-center" style="width: max-content">
|
||||
<q-btn
|
||||
v-if="isExtension"
|
||||
color="primary"
|
||||
@click="goToExtension()"
|
||||
label="Go To Extension"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else-if="g.isUserAuthorized"
|
||||
color="primary"
|
||||
@click="goToWallet()"
|
||||
label="Go to Wallet"
|
||||
></q-btn>
|
||||
<q-btn v-else color="primary" @click="goBack()" label="Go Back"></q-btn>
|
||||
<span class="q-mx-md">OR</span>
|
||||
<q-btn color="secondary" @click="goHome()" label="Go Home"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['dynamic', 'code', 'message'],
|
||||
computed: {
|
||||
isExtension() {
|
||||
if (this.code != 403) return false
|
||||
if (this.message.startsWith('Extension ')) return true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goBack() {
|
||||
window.history.back()
|
||||
},
|
||||
goHome() {
|
||||
window.location = '/'
|
||||
},
|
||||
goToWallet() {
|
||||
if (this.dynamic) {
|
||||
this.$router.push('/wallet')
|
||||
return
|
||||
}
|
||||
window.location = '/wallet'
|
||||
},
|
||||
goToExtension() {
|
||||
const extension = this.message.match(/'([^']+)'/)[1]
|
||||
const url = `/extensions#${extension}`
|
||||
if (this.dynamic) {
|
||||
this.$router.push(url)
|
||||
return
|
||||
}
|
||||
window.location = url
|
||||
},
|
||||
async logOut() {
|
||||
try {
|
||||
await LNbits.api.logout()
|
||||
window.location = '/'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
// check if we have error from error.html
|
||||
if (!this.dynamic) {
|
||||
if (this.code == 401) {
|
||||
console.warn(`Unauthorized: ${this.errorMessage}`)
|
||||
this.logOut()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
import LnbitsError from './lnbits-error.vue'
|
||||
|
||||
window.LnbitsError = LnbitsError
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user