Compare commits

..
Author SHA1 Message Date
Vlad Stan 648a657922 feat: download specific release 2026-02-09 12:40:36 +02:00
Vlad Stan cf251b1f33 chode: lint 2026-02-09 08:54:59 +02:00
Vlad Stan f94c4e432e fix: auth 2026-02-09 08:53:33 +02:00
Vlad Stan 6360c49ed0 feat: configure settings 2026-02-09 08:53:33 +02:00
Vlad Stan bfa35d9c69 refactor: extract methods and move them to the bottom 2026-02-09 08:53:33 +02:00
Vlad Stan 561d0976b3 fix: logging 2026-02-09 08:53:33 +02:00
Vlad Stan d40515e1ba fix: no logs 2026-02-09 08:53:33 +02:00
Vlad Stan 766fe3b5c6 feat: try to capture logs 2026-02-09 08:53:33 +02:00
Vlad Stan 82940a91b6 fix: better paths 2026-02-09 08:53:33 +02:00
Vlad Stan 5bd524660f feat: experiment node from python 2026-02-09 08:53:33 +02:00
Vlad Stan be2bb929c0 doc: add todos for missing doc 2026-02-09 08:53:33 +02:00
blackcoffeexbt 7d34f459fa Include error messages from Spark API in error message to users when payments from Spark fail 2026-02-05 12:26:17 +00:00
Arc 11d16620dd fundle 2026-01-29 05:02:31 +00:00
Arc c99bb28963 Merge branch 'origindev' into sparkwallet 2026-01-29 04:57:29 +00:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
arcbtc 2bd398b59c fixes 2026-01-23 00:41:18 +00:00
arcbtc 0e69aa536d bundle 2026-01-21 23:58:25 +00:00
arcbtc 984fc05caa Merge remote-tracking branch 'origin/dev' into sparkwallet 2026-01-21 23:57:42 +00:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
dni ⚡andGitHub ef9adc077b chore: update to v1.4.1 (#3731) 2026-01-20 13:01:16 +01:00
Vlad StanandGitHub ffad5d79c4 fix: undelete wallet (#3730) 2026-01-20 13:51:20 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
09c35eaca0 chore(deps-dev): bump filelock from 3.20.1 to 3.20.3 (#3721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 09:17:26 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
52adc62bc4 chore(deps): bump filelock from 3.20.0 to 3.20.3 (#3720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:40 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0efa173084 chore(deps): bump virtualenv from 20.35.4 to 20.36.1 (#3719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:21 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
724f8c9f63 chore(deps-dev): bump virtualenv from 20.31.2 to 20.36.1 (#3718)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:05 +01:00
Arc 213cde5ec6 make 2026-01-14 12:12:10 +00:00
Arc 2cd694c36e Merge remote-tracking branch 'origin/dev' into sparkwallet 2026-01-14 12:10:37 +00:00
Arc fb9cb6f2ed adds invoice stream 2026-01-14 12:05:59 +00:00
Arc 2852caeb42 moved to own repo 2026-01-13 13:36:28 +00:00
Vlad StanandGitHub c6981089e5 fix: handle pre-populated data (#3717) 2026-01-13 08:05:47 +01:00
Vlad Stan 13480027dd chore: bump release candidate 2026-01-12 13:33:53 +02:00
93f1006d3a feat: wire up paidreviews (#3656)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-12 13:17:39 +02:00
Tiago VasconcelosandGitHub 5490367179 fix: search input on extension page (#3705) 2026-01-12 12:39:48 +02:00
Arc 34fa193dc5 make 2026-01-10 15:51:39 +00:00
Arc 0f38fcfaa9 Needed longer wait time + poll 2026-01-10 15:48:13 +00:00
Arc 2cac4e6b4d sending kinda working 2026-01-10 15:39:35 +00:00
Arc 9983af4eec graceful shutdown 2026-01-10 15:26:43 +00:00
Arc a061dda5c1 make 2026-01-10 14:50:19 +00:00
Arc 6b233cc067 working 2026-01-10 14:22:17 +00:00
a185197e34 Fix: Update apipayments updated_at field when a payment is updated. (#3699)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-09 09:52:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa1bddcdd5 chore(deps-dev): bump werkzeug from 3.1.4 to 3.1.5 (#3714)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 06:26:32 +01:00
dni ⚡andGitHub 6d24ba55aa fix: /user/{user_id}/wallet should return deleted wallet (#3713) 2026-01-09 06:25:40 +01:00
Vlad StanandGitHub 5b2937672c fix: delete user with wallets (#3711) 2026-01-08 16:39:37 +01:00
Vlad StanandGitHub 957d9ce419 fix: password reset from UI (#3712) 2026-01-08 16:38:48 +01:00
PatMulliganandGitHub 0e21b77b74 fix(lndrest): use boolean for allow_self_payment instead of integer (#3700) 2026-01-08 09:54:05 +01:00
24e28911ab fix: greenlet import issue mac osx (#3698)
Co-authored-by: Pratik Patel <pratikpatelpp802@gmail.com>
2026-01-08 09:16:50 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e252d0485 chore(deps): bump urllib3 from 2.6.0 to 2.6.3 (#3706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:24 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e98550a125 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:10 +01:00
dni ⚡andGitHub bc55474859 fix: export qrcode was undefined (#3689) 2025-12-29 19:45:15 +01:00
dni ⚡andGitHub d985bdaadb chore: update breez liquid sdk (#3685) 2025-12-27 12:07:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
8e0d1cc100 chore(deps): bump fastapi-sso from 0.18.0 to 0.19.0 (#3681)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-12-23 09:18:33 +01:00
dni ⚡andGitHub cb3fd56647 chore: update to version v1.4.0 (#3684) 2025-12-22 14:34:03 +01:00
Vlad StanandGitHub 9f383bfee6 fix: cached user check (#3682) 2025-12-22 10:24:07 +01:00
dni ⚡andGitHub 3b2e28dd60 chore: update to v1.4.0-rc4 (#3675) 2025-12-22 09:27:16 +01:00
132192bc94 test: add boltz fundingsource to regtest (#3677)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-22 09:23:46 +01:00
281c3df826 fix: unlimited admin upload (#3679)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2025-12-19 20:11:34 +00:00
Vlad StanandGitHub 08591f34c2 fix: extension spinner not stopping after install (#3680) 2025-12-19 20:01:04 +00:00
ArcandGitHub f0e8ae0f5c Adds support for stripe readers (#3678) 2025-12-19 08:23:56 +01:00
168cb726b1 Make funding source fields more explicit (#3676)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-18 13:44:22 +00:00
dni ⚡andGitHub 91ac245307 fix: release lnbits-boltz with proper tag (#3674) 2025-12-17 14:36:29 +01:00
dni ⚡andGitHub d098e2f710 chore: update to v1.4.0-rc3 (#3672) 2025-12-17 13:57:22 +01:00
dni ⚡andGitHub a0f65f4cda fix: Boltzclient fundingsource. use a hashed wallet_name (#3673) 2025-12-17 13:57:05 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
606ee215b4 chore(deps-dev): bump filelock from 3.18.0 to 3.20.1 (#3669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 09:13:18 +01:00
dni ⚡andGitHub 7716f819f6 CI: create github prerelease and appimage (#3668) 2025-12-17 07:21:47 +01:00
arcbtc 8325d880cb Extra precaution
Just in case someone in lnbits org creates a test release on a fork
2025-12-16 13:45:52 +00:00
dni ⚡andGitHub b090470fc1 fix: CI getting release upload url (#3667) 2025-12-16 14:12:00 +01:00
dni ⚡andGitHub c7297d2e77 feat: add the ability to print qrcodes to <lnbits-qrcode> component (#3664) 2025-12-16 12:12:11 +00:00
dni ⚡andGitHub b6e111b21c fix: dont log routes.json 404 (#3665) 2025-12-16 13:10:02 +01:00
dni ⚡andGitHub 7747d7b741 fix: path check for ext pages with arguments (#3660) 2025-12-11 13:56:49 +01:00
Tiago VasconcelosandGitHub f3a5a8e002 fix: account dropdown (#3658) 2025-12-11 13:17:17 +01:00
Tiago VasconcelosandGitHub 157a6485b4 fix: restore the blur on drawer (#3654) 2025-12-11 13:16:32 +01:00
dni ⚡andGitHub 8867b27b09 fix: LOCALE global variable still used in extension (#3659) 2025-12-11 13:03:38 +01:00
dni ⚡andGitHub 68b607ecbc fix: add spacing to the logo (#3653) 2025-12-10 08:08:00 +01:00
118 changed files with 4110 additions and 1983 deletions
+7
View File
@@ -23,3 +23,10 @@ mypy.ini
package-lock.json
package.json
pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
+3 -4
View File
@@ -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
@@ -56,7 +55,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, LightsparkSparkWallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -187,7 +186,8 @@ 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"
# TODO: add Spark
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
@@ -333,4 +333,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
###### Logging and Development #######
######################################
+8 -1
View File
@@ -75,7 +75,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 }}
+1
View File
@@ -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 }}
+4 -2
View File
@@ -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
+30
View File
@@ -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 }}
+12 -2
View File
@@ -14,7 +14,7 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -22,10 +22,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
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:
@@ -35,6 +43,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -44,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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -57,7 +57,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
---
## TODO: add Spark
### Notes for readers
+3 -1
View File
@@ -52,7 +52,7 @@ A backend wallet is selected and configured entirely through LNbits environment
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables**
@@ -338,6 +338,8 @@ Configure in the admin UI or via env vars:
<a id="strike"></a>
## TODO: add Spark
## Strike (alpha)
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
+12 -2
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from time import time
from typing import Any
@@ -304,8 +305,16 @@ async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
)
@@ -314,6 +323,7 @@ async def update_payment(
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
+10 -5
View File
@@ -45,17 +45,13 @@ async def update_wallet(
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Connection | None = None,
) -> None:
_clear_wallet_cache(wallet_id)
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)
@@ -69,6 +65,7 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
_clear_wallet_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -78,6 +75,7 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
_clear_wallet_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -294,3 +292,10 @@ async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
def _clear_wallet_cache(wallet_id):
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}")
+48 -2
View File
@@ -12,7 +12,7 @@ from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, Field
from lnbits.helpers import (
download_url,
@@ -269,10 +269,30 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e:
logger.warning(e)
return []
@@ -777,6 +797,32 @@ class ExtensionDetailsRequest(BaseModel):
version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
+25
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
@@ -55,6 +57,29 @@ class DataField(BaseModel):
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
+27 -13
View File
@@ -3,6 +3,7 @@ import io
from uuid import uuid4
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
@@ -16,7 +17,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if user_id not in settings.lnbits_assets_no_limit_users:
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(
@@ -29,17 +30,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
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)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
@@ -48,9 +39,32 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents,
)
await create_asset(asset)
return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
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)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+44 -11
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import (
camel_to_words,
download_url,
lowercase_first_letter,
snake_to_camel,
)
from lnbits.settings import settings
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
shutil.copytree(
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
rederer = _render_file(
template_path,
{
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
},
"client_data": {
"name": data.client_data.name,
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
},
"settings_data": {
"enabled": data.settings_data.enabled,
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
def _is_excluded_dir(path: str) -> bool:
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
+4 -4
View File
@@ -57,11 +57,11 @@ async def api_get_asset(
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
"/{asset_id}/data",
name="Get user asset data",
summary="Get user asset data data by ID",
)
async def api_get_asset_binary(
async def api_get_asset_data(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
+60 -1
View File
@@ -4,9 +4,10 @@ import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Annotated
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
@@ -29,6 +30,7 @@ from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import (
@@ -120,6 +122,63 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl")
async def api_get_user_acls(
request: Request,
+53
View File
@@ -2,8 +2,10 @@ import sys
import traceback
from http import HTTPStatus
import httpx
from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
@@ -14,10 +16,14 @@ from lnbits.core.models import (
)
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
@@ -34,6 +40,7 @@ from lnbits.core.services.extensions import (
install_extension,
uninstall_extension,
)
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
@@ -589,3 +596,49 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
for ext in installable_exts
]
return extension_data
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+18 -31
View File
@@ -42,54 +42,38 @@ async def robots():
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
"/extensions/builder/preview/{ext_id}/{resource}",
name="extensions builder",
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
resource: str | None = None,
page_name: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
) -> FileResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path(
file_name = "index"
if page_name == "public_page":
file_name = "public_page"
resource_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
"static",
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
file_ext = ".vue" if resource == "template" else ".js"
file_full_path = Path(resource_path, file_name + file_ext)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"status_code": 404,
"message": f"Extension {ext_id} not found, refresh Preview.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
},
)
if not file_full_path.is_file():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
@@ -208,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
+2 -2
View File
@@ -337,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}
@@ -346,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,
}
+5 -7
View File
@@ -20,6 +20,7 @@ from lnbits.core.crud import (
update_admin_settings,
update_wallet,
)
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import (
AccountFilters,
AccountOverview,
@@ -157,11 +158,8 @@ async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
@@ -224,7 +222,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id)
return await get_wallets(user_id, deleted=None)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
@@ -247,7 +245,7 @@ async def api_users_create_user_wallet(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
wal = await get_wallet(wallet, deleted=True)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
+7 -2
View File
@@ -337,7 +337,8 @@ class Database(Compat):
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
# Parse and add UTC timezone info
return datetime.strptime(value, f).replace(tzinfo=timezone.utc)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
@@ -756,7 +757,11 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
# Ensure PostgreSQL datetime values have timezone info
if isinstance(value, datetime) and value.tzinfo is None:
_dict[key] = value.replace(tzinfo=timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
+1 -1
View File
@@ -249,7 +249,7 @@ async def check_account_id_exists(
account_id = cache.get(cache_key)
if account_id:
r.scope["user_id"] = account_id.id
await _check_user_access(r, account_id, conn=conn)
await _check_user_access(r, account_id.id, conn=conn)
return account_id
account = await _check_account_exists(r, access_token, usr, conn=conn)
+4 -2
View File
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
content={"detail": [dict(e) for e in exc.errors()]},
)
@app.exception_handler(HTTPException)
@@ -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(
+21
View File
@@ -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):
@@ -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,
@@ -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
)
+9
View File
@@ -107,6 +107,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_EXTENSIONS_REVIEWS_URL": settings.lnbits_extensions_reviews_url,
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
@@ -390,6 +391,14 @@ def camel_to_snake(name: str) -> str:
return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
+30 -1
View File
@@ -52,6 +52,13 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
URL for the paid reviews.
Regular users can view this URL (not secret).
""",
)
lnbits_extensions_manifests: list[str] = Field(
default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
@@ -299,6 +306,10 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -308,6 +319,12 @@ class AssetSettings(LNbitsSettings):
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):
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
@@ -572,6 +589,17 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_network: str = Field(default="MAINNET")
spark_l2_internal_sidecar_version: str | None = Field(default="0.1.1")
spark_l2_external_endpoint: str | None = Field(default=None)
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None)
@@ -601,7 +629,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)
@@ -678,6 +705,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
@@ -1005,6 +1033,7 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -217,6 +217,7 @@ body.body--dark .q-header,
body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8);
}
:root {
+21
View File
@@ -187,6 +187,25 @@ window.localisation.en = {
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
reviews_url_hint:
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'View reviews',
reviews_leave: 'Leave a review',
reviews_name: 'Your name',
reviews_comment: 'Your review',
reviews_rating: 'Rating',
reviews_submit: 'Submit review',
reviews_loading: 'Loading reviews...',
reviews_refresh: 'Refresh reviews',
reviews_error_load: 'Could not load reviews',
reviews_url_not_configured: 'Reviews URL not configured',
reviews_pay_invoice: 'Pay invoice',
reviews_invoice_paid: 'Invoice paid',
reviews_invoice_title: 'Pay this invoice to submit your review',
reviews_count: 'Reviews',
no_reviews: 'No reviews yet',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:',
@@ -676,6 +695,8 @@ window.localisation.en = {
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
+13
View File
@@ -123,6 +123,19 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout'
})
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
+2 -1
View File
@@ -13,7 +13,8 @@ window.LNbits = {
wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user,
extra: data.extra ?? {}
extra: data.extra ?? {},
hasPassword: data.has_password ?? false
}
const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
+20 -20
View File
@@ -3,26 +3,37 @@ window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating',
props: ['rating']
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: {
amount: {
rating: {
type: Number,
default: 0
},
count: {
type: Number,
default: null
},
clickable: {
type: Boolean,
default: false
}
},
computed: {
fsat() {
return LNbits.utils.formatSat(this.amount)
displayRating() {
return Math.round((this.rating || 0) * 2) / 2
},
hasData() {
return this.count !== null && this.count !== undefined
}
},
methods: {
handleClick() {
if (!this.clickable) return
this.$emit('click')
}
}
})
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
computed: {
showAdmin() {
@@ -54,10 +65,8 @@ window.app.component('lnbits-manage', {
})
window.app.component('lnbits-payment-details', {
mixins: [window.windowMixin],
template: '#lnbits-payment-details',
props: ['payment'],
mixins: [window.windowMixin],
computed: {
hasPreimage() {
return (
@@ -101,7 +110,6 @@ window.app.component('lnbits-payment-details', {
})
window.app.component('lnbits-lnurlpay-success-action', {
mixins: [window.windowMixin],
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'],
data() {
@@ -121,7 +129,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
return {
@@ -292,7 +299,6 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'modelValue'],
data() {
return {
@@ -325,7 +331,6 @@ window.app.component('lnbits-dynamic-fields', {
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
@@ -356,7 +361,6 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'small_btn'],
computed: {
admin() {
@@ -395,7 +399,6 @@ window.app.component('lnbits-update-balance', {
window.app.component('user-id-only', {
template: '#user-id-only',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authAction: String,
@@ -439,7 +442,6 @@ window.app.component('user-id-only', {
window.app.component('username-password', {
template: '#username-password',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authMethods: Array,
@@ -677,7 +679,6 @@ window.app.component('lnbits-stat', {
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
@@ -756,7 +757,6 @@ window.app.component('lnbits-node-info', {
showDialog: false
}
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-assets-config', {
props: ['form-data'],
template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() {
return {
newAllowedAssetMimeType: '',
@@ -17,6 +16,7 @@ window.app.component('lnbits-admin-assets-config', {
this.newAllowedAssetMimeType
)
this.newAllowedAssetMimeType = ''
this.formData.touch = null
}
},
removeAllowedAssetMimeType(type) {
@@ -24,18 +24,21 @@ window.app.component('lnbits-admin-assets-config', {
if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
}
this.formData.touch = null
},
addNewNoLimitUser() {
if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = ''
this.formData.touch = null
}
},
removeNoLimitUser(user) {
if (user) {
this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
this.formData.touch = null
}
}
}
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-audit', {
props: ['form-data'],
template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() {
return {
formAddIncludePath: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'],
template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() {
return {
exchangeData: {
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-extensions', {
props: ['form-data'],
template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() {
return {
formAddExtensionsManifest: ''
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'],
template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() {
return {
formAddStripeUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
@@ -89,8 +88,14 @@ window.app.component('lnbits-admin-funding-sources', {
lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: 'Enable Route Hints',
lnd_rest_allow_self_payment: 'Allow Self Payment'
lnd_rest_route_hints: {
advanced: true,
label: 'Enable Route Hints'
},
lnd_rest_allow_self_payment: {
advanced: true,
label: 'Allow Self Payment'
}
}
],
[
@@ -160,16 +165,27 @@ window.app.component('lnbits-admin-funding-sources', {
'BoltzWallet',
'Boltz',
{
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name',
boltz_client_password: 'Wallet Password (can be empty)',
boltz_client_endpoint: {
label: 'Boltz client endpoint',
value: '127.0.0.1:9002'
},
boltz_client_macaroon: {
label: 'Admin Macaroon path or hex',
value: '/home/ubuntu/.boltz/macaroons/admin.macaroon'
},
boltz_client_cert: {
label: 'Certificate path or hex',
value: '/home/ubuntu/.boltz/tls.cert'
},
boltz_mnemonic: {
label: 'Liquid mnemonic (copy into greenwallet)',
readonly: true,
label: 'Liquid seed phrase',
hint: 'Boltz will fetch once connected, but you can change later (can be opened in a liquid wallet) ',
copy: true,
qrcode: true
},
boltz_client_password: {
label: 'Wallet Password (optional)',
advanced: true
}
}
],
@@ -212,6 +228,51 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token'
}
],
[
'LightsparkSparkWallet',
'Spark (L2)',
{
spark_l2_internal_sidecar_version: {
label: 'Internal Sidecar Version (eg: 0.1.1).',
hint: 'If specified then the sidecar will be downloaded. Alternatively you can specify an External Sidecar endpoint in the Advanced section.',
value: ''
},
spark_l2_mnemonic: {
label: 'Mnemonic',
hint: 'Only required if Interna Sidecar version is specified.'
},
spark_l2_network: {
label: 'Network',
value: 'MAINNET'
},
spark_l2_external_endpoint: {
label: 'External Sidecar Endpoint. ',
hint: 'If specified then this endpoint will be used instead of the internal sidecar. Make sure to also specify the API key if your sidecar requires authentication.',
value: '',
advanced: true
},
spark_l2_external_api_key: {
label: 'External Sidecar API Key. ',
hint: 'API Key for authenticating with the external sidecar if it requires authentication.',
value: '',
advanced: true
},
spark_l2_pay_wait_ms: {
label: 'Pay Wait Time (ms)',
advanced: true
},
spark_l2_pay_poll_ms: {
label: 'Pay Poll Time (ms)',
advanced: true
},
spark_l2_stream_keepalive_ms: {
label: 'Stream Keepalive Time (ms)',
advanced: true
}
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() {
return {
auditData: []
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-notifications', {
props: ['form-data'],
template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() {
return {
nostrNotificationIdentifier: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-security', {
props: ['form-data'],
template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() {
return {
logs: [],
@@ -1,5 +1,4 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin]
template: '#lnbits-admin-server'
})
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'],
template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() {
return {
lnbits_theme_options: [
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-users', {
props: ['form-data'],
template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() {
return {
formAddUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: {
showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized
+1 -2
View File
@@ -1,4 +1,3 @@
window.app.component('lnbits-drawer', {
template: '#lnbits-drawer',
mixins: [window.windowMixin]
template: '#lnbits-drawer'
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-error', {
template: '#lnbits-error',
mixins: [window.windowMixin],
props: ['dynamic', 'code', 'message'],
computed: {
isExtension() {
@@ -1,6 +1,5 @@
window.app.component('lnbits-footer', {
template: '#lnbits-footer',
mixins: [window.windowMixin],
computed: {
version() {
return this.LNBITS_VERSION || 'unknown version'
@@ -1,4 +1,3 @@
window.app.component('lnbits-header-wallets', {
template: '#lnbits-header-wallets',
mixins: [window.windowMixin]
template: '#lnbits-header-wallets'
})
+12 -1
View File
@@ -1,6 +1,5 @@
window.app.component('lnbits-header', {
template: '#lnbits-header',
mixins: [window.windowMixin],
computed: {
hasServiceFeeMax() {
return (
@@ -61,5 +60,17 @@ window.app.component('lnbits-header', {
return 'User'
}
}
},
methods: {
async stopImpersonation() {
try {
await LNbits.api.stopImpersonation()
LNbits.utils.restoreLocalStorage('impersonation')
window.location = '/users'
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
}
}
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-home-logos', {
template: '#lnbits-home-logos',
mixins: [window.windowMixin],
data() {
return {
logos: [
@@ -1,7 +1,6 @@
window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector',
props: ['labels'],
mixins: [window.windowMixin],
data() {
return {
labelFilter: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown',
mixins: [window.windowMixin],
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
@@ -1,5 +1,4 @@
window.app.component('lnbits-manage-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-manage-extension-list',
data() {
return {
@@ -1,6 +1,5 @@
window.app.component('lnbits-manage-wallet-list', {
template: '#lnbits-manage-wallet-list',
mixins: [window.windowMixin],
data() {
return {
activeWalletId: null
@@ -1,7 +1,6 @@
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
payments: [],
@@ -1,6 +1,5 @@
window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: {
url: {
required: true,
+39 -1
View File
@@ -1,5 +1,4 @@
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue: QrcodeVue.default
@@ -13,6 +12,10 @@ window.app.component('lnbits-qrcode', {
type: Boolean,
default: false
},
print: {
type: Boolean,
default: false
},
showButtons: {
type: Boolean,
default: true
@@ -41,6 +44,41 @@ window.app.component('lnbits-qrcode', {
}
},
methods: {
printQrCode() {
const svg = this.$refs.qrCode.$el.outerHTML
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<html>
<head>
<title>Print QR Code</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
img {
position: absolute;
max-width: 51px;
width: 14%;
overflow: hidden;
background: #fff;
overflow: hidden;
padding: -1.2rem;
border-radius: -1.2rem;
}
</style>
</head>
<body>${svg}<img src="${this.logo}"></body>
</html>
`)
printWindow.document.close()
printWindow.focus()
printWindow.print()
printWindow.close()
},
clickQrCode(event) {
if (this.href === '') {
this.utils.copyText(this.value)
@@ -1,5 +1,4 @@
window.app.component('lnbits-theme', {
mixins: [window.windowMixin],
watch: {
'g.walletFlip'(val) {
this.$q.localStorage.setItem('lnbits.walletFlip', val)
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-api-docs', {
template: '#lnbits-wallet-api-docs',
mixins: [window.windowMixin],
methods: {
resetKeys() {
LNbits.utils
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-charts', {
template: '#lnbits-wallet-charts',
mixins: [window.windowMixin],
props: ['paymentFilter', 'chartConfig'],
data() {
return {
@@ -1,9 +1,10 @@
window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra',
mixins: [window.windowMixin],
props: ['chartConfig'],
data() {
return {}
computed: {
exportUrl() {
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
}
},
methods: {
handleSendLnurl(lnurl) {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-icon', {
template: '#lnbits-wallet-icon',
mixins: [window.windowMixin],
data() {
return {
icon: {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-new', {
template: '#lnbits-wallet-new',
mixins: [window.windowMixin],
data() {
return {
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-paylinks', {
template: '#lnbits-wallet-paylinks',
mixins: [window.windowMixin],
data() {
return {
storedPaylinks: []
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-share', {
template: '#lnbits-wallet-share',
mixins: [window.windowMixin],
computed: {
walletApprovedShares() {
return this.g.wallet.extra.shared_with.filter(
+1
View File
@@ -15,6 +15,7 @@ window.g = Vue.reactive({
wallet: null,
isPublicPage: true,
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'),
offline: !navigator.onLine,
hasCamera: false,
visibleDrawer: false,
+9 -1
View File
@@ -38,7 +38,7 @@ const DynamicComponent = {
let route = RENDERED_ROUTE
// append trailing slash only on the root path `/path` -> `/path/`
if (route.split('/').length === 2) route += '/'
if (route !== this.$route.fullPath) {
if (route !== this.$route.path) {
console.log('Redirecting to non-vue route:', this.$route.fullPath)
window.location = this.$route.fullPath
return
@@ -106,6 +106,11 @@ const routes = [
name: 'ExtensionsBuilder',
component: PageExtensionBuilder
},
{
path: '/extensions/builder/preview',
name: 'ExtensionsBuilderPreview',
component: PageExtensionBuilderPreview
},
{
path: '/extensions',
name: 'Extensions',
@@ -138,6 +143,9 @@ window.router = VueRouter.createRouter({
routes
})
// BACKWARDS compatibility extensions
window.LOCALE = window.g.locale
window.i18n = new VueI18n.createI18n({
locale: window.g.locale,
fallbackLocale: 'en',
+1 -2
View File
@@ -1,6 +1,5 @@
window.PageAccount = {
template: '#page-account',
mixins: [window.windowMixin],
data() {
return {
untouchedUser: null,
@@ -610,7 +609,7 @@ window.PageAccount = {
}
},
copyAssetLinkToClipboard(asset) {
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary`
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/data`
this.utils.copyText(assetUrl)
},
addUserLabel() {
-1
View File
@@ -1,6 +1,5 @@
window.PageAdmin = {
template: '#page-admin',
mixins: [windowMixin],
data() {
return {
tab: 'funding',
-1
View File
@@ -1,6 +1,5 @@
window.PageAudit = {
template: '#page-audit',
mixins: [window.windowMixin],
data() {
return {
chartsReady: false,
+1 -2
View File
@@ -1,4 +1,3 @@
window.PageError = {
template: '#page-error',
mixins: [window.windowMixin]
template: '#page-error'
}
+234 -24
View File
@@ -1,6 +1,5 @@
window.PageExtensions = {
template: '#page-extensions',
mixins: [window.windowMixin],
data() {
return {
extbuilderEnabled: false,
@@ -26,7 +25,63 @@ window.PageExtensions = {
selectedRelease: null,
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null
paylinkWebsocket: null,
searchToggle: false,
reviewsUrl: null,
reviewsDialog: {
show: false,
extension: null,
loading: false,
submitting: false,
form: {
name: '',
rating: 0,
comment: ''
},
error: null
},
reviews: [],
reviewsTable: {
loading: false,
columns: [
{
name: 'name',
align: 'left',
label: this.$t('Name'),
field: 'name',
sortable: true
},
{
name: 'comment',
align: 'left',
label: this.$t('Comment'),
field: 'comment'
},
{
name: 'created_at',
align: 'left',
label: this.$t('Date'),
field: 'created_at'
},
{
name: 'rating',
align: 'right',
label: 'Rating',
field: 'rating'
}
],
pagination: {
rowsPerPage: 5,
sortBy: 'created_at',
descending: true,
page: 1
}
},
paymentDialog: {
show: false,
invoice: '',
hash: ''
}
}
},
watch: {
@@ -71,7 +126,6 @@ window.PageExtensions = {
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const extension = this.selectedExtension
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
release.payment_hash =
@@ -86,6 +140,7 @@ window.PageExtensions = {
version: release.version
})
.then(response => {
this.selectedExtension.inProgress = false
const extension = this.extensions.find(
ext => ext.id === this.selectedExtension.id
)
@@ -100,7 +155,7 @@ window.PageExtensions = {
})
.catch(err => {
console.warn(err)
extension.inProgress = false
this.selectedExtension.inProgress = false
LNbits.utils.notifyApiError(err)
})
},
@@ -284,8 +339,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/releases`,
this.g.user.wallets[0].adminkey
`/api/v1/extension/${extension.id}/releases`
)
this.selectedExtensionRepos = data.reduce((repos, release) => {
@@ -323,6 +377,9 @@ window.PageExtensions = {
if (!detailsLink) {
return
}
// keep a reference to the extension so we can show rating stats
this.selectedExtension =
this.extensions.find(ext => ext.id === extId) || this.selectedExtension
this.selectedExtensionDetails = null
this.showExtensionDetailsDialog = true
this.slide = 0
@@ -331,8 +388,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`,
this.g.user.wallets[0].inkey
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`
)
this.selectedExtensionDetails = data
@@ -453,7 +509,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/${extId}/invoice/install`,
this.g.user.wallets[0].adminkey,
null,
{
ext_id: extId,
archive: release.archive,
@@ -469,7 +525,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/${extId}/invoice/enable`,
this.g.user.wallets[0].adminkey,
null,
{
amount
}
@@ -560,8 +616,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
this.g.user.wallets[0].adminkey
`/api/v1/extension/release/${org}/${repo}/${release.version}`
)
release.loaded = true
release.is_version_compatible = data.is_version_compatible
@@ -586,18 +641,13 @@ window.PageExtensions = {
continue
}
ext.inProgress = true
await LNbits.api.request(
'POST',
`/api/v1/extension`,
this.g.user.wallets[0].adminkey,
{
ext_id: ext.id,
archive: ext.latestRelease.archive,
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
}
)
await LNbits.api.request('POST', `/api/v1/extension`, null, {
ext_id: ext.id,
archive: ext.latestRelease.archive,
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
})
count++
ext.isAvailable = true
ext.isInstalled = true
@@ -622,6 +672,163 @@ window.PageExtensions = {
})
this.showUpdateAllDialog = false
},
formatAvg(raw) {
const val = Number(raw || 0)
return Math.round((val / 2 / 100) * 2) / 2
},
async loadReviewStats() {
if (!this.reviewsUrl) {
console.info('Extension reviews are not configured')
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/tags`
)
const map = {}
data.forEach(stat => {
map[stat.tag] = stat
})
this.extensions.forEach(ext => {
ext.reviewStats = map[ext.id] || null
})
this.filterExtensions(this.searchTerm, this.tab)
} catch (error) {
console.warn(error)
}
},
async openReviews(extension) {
const targetExtension =
extension ||
(this.selectedExtensionDetails
? this.extensions.find(e => e.id === this.selectedExtensionDetails.id)
: null)
if (!targetExtension) {
return
}
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
this.reviewsDialog.extension = targetExtension
this.selectedExtension = extension
this.reviewsDialog.show = true
await this.getTagReviews()
},
async getTagReviews(props) {
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
try {
this.reviewsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.reviewsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/${this.selectedExtension.id}?${params}`
)
this.reviews = data.data
this.reviewsTable.pagination.rowsNumber = data.total
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.reviewsTable.loading = false
}
},
formatReviewDate(val) {
if (!val) return ''
const numeric = Number(val)
if (!Number.isNaN(numeric)) {
return this.utils.formatTimestamp(numeric)
}
return this.utils.formatDate(val)
},
async submitReview() {
if (!this.reviewsDialog.extension || !this.reviewsUrl) {
return
}
this.reviewsDialog.submitting = true
try {
const payload = {
tag: this.reviewsDialog.extension.id,
name: this.reviewsDialog.form.name,
rating: this.reviewsDialog.form.rating * 100,
comment: this.reviewsDialog.form.comment
}
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/reviews`,
null,
payload
)
if (data.payment_request) {
this.openInvoiceDialog(data.payment_request, data.payment_hash)
} else {
Quasar.Notify.create({
type: 'positive',
message: 'Review submitted'
})
this.resetReviewForm()
await this.getTagReviews()
await this.loadReviewStats()
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.reviewsDialog.submitting = false
}
},
openInvoiceDialog(invoice, hash) {
this.paymentDialog.invoice = invoice
this.paymentDialog.hash = hash
this.paymentDialog.show = true
this.listenForPayment(hash)
},
resetReviewForm() {
this.reviewsDialog.form = {
name: '',
rating: 0,
comment: ''
}
this.paymentDialog = {
show: false,
invoice: '',
hash: ''
}
},
listenForPayment(hash) {
try {
const base = new URL(this.reviewsUrl)
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:'
base.pathname = `/api/v1/ws/${hash}`
const ws = new WebSocket(base)
ws.addEventListener('message', async () => {
Quasar.Notify.create({
type: 'positive',
message: this.$t('reviews_invoice_paid')
})
this.paymentDialog.show = false
this.resetReviewForm()
setTimeout(async () => {
await this.getTagReviews()
}, 1000)
await this.loadReviewStats()
ws.close()
})
} catch (error) {
console.warn(error)
}
},
async fetchAllExtensions() {
try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
@@ -636,6 +843,7 @@ window.PageExtensions = {
async created() {
this.extensions = await this.fetchAllExtensions()
this.extbuilderEnabled = this.g.user.admin || this.LNBITS_EXT_BUILDER
this.reviewsUrl = this.LNBITS_EXTENSIONS_REVIEWS_URL
if (this.g.user.extensions.length === 0) {
this.tab = 'all'
@@ -652,6 +860,8 @@ window.PageExtensions = {
this.hasNewVersion(ext)
)
await this.loadReviewStats()
this.filterExtensions(this.searchTerm, this.tab)
}
}
+18 -7
View File
@@ -1,6 +1,5 @@
window.PageExtensionBuilder = {
template: '#page-extension-builder',
mixins: [windowMixin],
data() {
return {
step: 1,
@@ -288,11 +287,10 @@ window.PageExtensionBuilder = {
`/api/v1/extension/${stub_ext_id}/releases`
)
this.extensionStubVersions = data.sort((a, b) =>
a.version < b.version ? 1 : -1
)
this.extensionData.stub_version = this.extensionStubVersions[0]
? this.extensionStubVersions[0].version
this.extensionStubVersions = data
const validVersions = data.filter(v => v.is_version_compatible)
this.extensionData.stub_version = validVersions[0]
? validVersions[0].version
: ''
} catch (error) {
LNbits.utils.notifyApiError(error)
@@ -311,7 +309,20 @@ window.PageExtensionBuilder = {
iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top'
}
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
let componentName =
'Page' +
this.extensionData.id
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
if (previewPageName === 'public_page') componentName += 'Public'
iframe.src =
`/extensions/builder/preview?` +
`ext_id=${this.extensionData.id}` +
`&page=${previewPageName}` +
`&component=${componentName}`
},
initBasicData() {
this.extensionData.owner_data.fields = [
@@ -0,0 +1,46 @@
window.PageExtensionBuilderPreview = {
template: '#page-extension-builder-preview',
mixins: [windowMixin],
watch: {
name: 'reload'
},
data() {
return {
extId: '',
pageName: '',
componentName: null
}
},
methods: {
async reload() {
await LNbits.utils.loadTemplate(
`/extensions/builder/preview/${this.extId}/template?page_name=${this.pageName}`
)
await LNbits.utils.loadScript(
`/extensions/builder/preview/${this.extId}/component?page_name=${this.pageName}`
)
this._component = window[this.componentName]
console.log(
'LNbits preview reloaded componentName:',
this.componentName,
!!this._component
)
this.$forceUpdate()
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search)
this.extId = urlParams.get('ext_id') || ''
this.pageName = urlParams.get('page') || ''
this.componentName = urlParams.get('component') || ''
await this.reload()
},
render() {
if (this._component) {
return Vue.h(this._component)
}
return Vue.h('div', 'Loading...')
}
}
-1
View File
@@ -1,6 +1,5 @@
window.PageFirstInstall = {
template: '#page-first-install',
mixins: [window.windowMixin],
data() {
return {
loginData: {
-1
View File
@@ -1,6 +1,5 @@
window.PageHome = {
template: '#page-home',
mixins: [window.windowMixin],
data() {
return {
lnurl: '',
-1
View File
@@ -1,6 +1,5 @@
window.PageNodePublic = {
template: '#page-node-public',
mixins: [window.windowMixin],
data() {
return {
enabled: false,
-1
View File
@@ -1,5 +1,4 @@
window.PageNode = {
mixins: [window.windowMixin],
template: '#page-node',
config: {
globalProperties: {
-1
View File
@@ -1,6 +1,5 @@
window.PagePayments = {
template: '#page-payments',
mixins: [window.windowMixin],
data() {
return {
payments: [],
+14 -1
View File
@@ -1,6 +1,5 @@
window.PageUsers = {
template: '#page-users',
mixins: [window.windowMixin],
data() {
return {
paymentsWallet: {},
@@ -439,6 +438,20 @@ window.PageUsers = {
this.activeUser.show = false
}
},
async impersonateUser(user_id) {
try {
await LNbits.api.impersonateUser(user_id)
LNbits.utils.backupLocalStorage('impersonation', true)
this.$q.localStorage.setItem('lnbits.disclaimerShown', true)
window.location = '/wallet'
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'warning',
message: 'Failed to impersonate user!'
})
}
},
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
-1
View File
@@ -1,6 +1,5 @@
window.PageWallet = {
template: '#page-wallet',
mixins: [window.windowMixin],
data() {
return {
parse: {
-1
View File
@@ -1,6 +1,5 @@
window.PageWallets = {
template: '#page-wallets',
mixins: [window.windowMixin],
data() {
return {
user: null,
+52 -11
View File
@@ -67,6 +67,29 @@ window._lnbitsUtils = {
}
})
},
backupLocalStorage(backupKey, cleanup = false) {
const lnbitsEntries =
Object.entries(Quasar.LocalStorage.getAll()).filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
) || []
Quasar.LocalStorage.setItem(`lnbits.${backupKey}`, lnbitsEntries)
if (cleanup) {
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
}
},
restoreLocalStorage(backupKey) {
Object.entries(Quasar.LocalStorage.getAll())
.filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
)
.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
const lnbitsEntries =
Quasar.LocalStorage.getItem(`lnbits.${backupKey}`) || []
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.setItem(k, v))
Quasar.LocalStorage.remove(`lnbits.${backupKey}`)
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
@@ -114,6 +137,13 @@ window._lnbitsUtils = {
formatMsat(value) {
return this.formatSat(value / 1000)
},
parseJSONSafe(str) {
try {
return JSON.parse(str)
} catch (e) {
return null
}
},
notifyApiError(error) {
if (!error.response) {
return console.error(error)
@@ -123,17 +153,28 @@ window._lnbitsUtils = {
401: 'warning',
500: 'negative'
}
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message:
error.response.data.message || error.response.data.detail || null,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null
})
let messages = error.response.data.detail
if (messages) {
messages = Array.isArray(messages)
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
: (messages = [messages])
} else {
messages = [error.response.data.message || error.response.data.detail]
}
messages.forEach(message =>
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null,
closeBtn: true
})
)
},
search(data, q, field, separator) {
try {
+1
View File
@@ -63,5 +63,6 @@ body.body--dark {
.q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8);
}
}
+2
View File
@@ -45,6 +45,8 @@
"js/pages/error.js",
"js/pages/home.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js",
"js/pages/first-install.js",
"js/pages/payments.js",
+27 -6
View File
@@ -534,7 +534,7 @@ include('components/lnbits-error.vue') %}
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs>
<lnbits-qrcode :value="lnurl" nfc="true"></lnbits-qrcode>
<lnbits-qrcode :value="lnurl" nfc="true" print="true"></lnbits-qrcode>
</div>
</template>
@@ -561,11 +561,32 @@ include('components/lnbits-error.vue') %}
</template>
<template id="lnbits-extension-rating">
<div style="margin-bottom: 3px">
<q-rating v-model="rating" size="1.5em" :max="5" color="primary"
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
<div class="row items-center q-gutter-xs" style="margin-bottom: 3px">
<q-rating
:model-value="displayRating"
size="1.4em"
:max="5"
color="primary"
icon="star_border"
icon-selected="star"
icon-half="star_half"
readonly
:class="clickable ? 'cursor-pointer' : ''"
@click="handleClick"
>
<q-tooltip v-if="!hasData">
<span v-text="$t('extension_rating_soon')"></span>
</q-tooltip>
<q-tooltip v-else-if="clickable">
<span v-text="$t('reviews_open')"></span>
</q-tooltip>
</q-rating>
<div
class="text-caption text-grey"
v-if="count !== null && count !== undefined"
>
<span v-text="`(${count})`"></span>
</div>
</div>
</template>
@@ -137,6 +137,19 @@
:hint="$t('extension_builder_manifest_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('reviews_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_reviews_url"
:label="$t('reviews_url_label')"
:hint="$t('reviews_url_hint')"
type="url"
autocomplete="off"
></q-input>
</div>
</div>
</div>
</q-card-section>
@@ -35,7 +35,9 @@
>
<div
class="row"
v-for="([key, prop], i) in Object.entries(fundingSources.get(fund))"
v-for="([key, prop], i) in Object.entries(
fundingSources.get(fund)
).filter(([, p]) => !p.advanced)"
:key="i"
>
<div class="col-12">
@@ -46,7 +48,7 @@
:type="hideInput ? 'password' : 'text'"
:label="prop.label"
:hint="prop.hint"
:readonly="prop.readonly || false"
:value="prop.value"
>
<q-btn
v-if="prop.copy"
@@ -69,6 +71,55 @@
</q-input>
</div>
</div>
<q-expansion-item
v-if="
Object.entries(fundingSources.get(fund)).some(([, p]) => p.advanced)
"
dense
expand-separator
icon="tune"
label="Advanced"
class="q-mt-sm"
>
<div
class="row"
v-for="([key, prop], i) in Object.entries(
fundingSources.get(fund)
).filter(([, p]) => p.advanced)"
:key="`adv-${i}`"
>
<div class="col-12">
<q-input
v-model="formData[key]"
filled
class="q-mt-sm"
:type="hideInput ? 'password' : 'text'"
:label="prop.label"
:hint="prop.hint"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@click="utils.copyText(formData[key])"
icon="content_copy"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
<q-btn
v-if="prop.qrcode"
@click="showQRValue(formData[key])"
icon="qr_code"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
</q-input>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
<q-dialog v-model="showQRDialog">
+29 -12
View File
@@ -22,7 +22,7 @@
@click="g.visibleDrawer = !g.visibleDrawer"
></q-btn>
<q-toolbar-title>
<q-btn flat no-caps dense size="lg" type="a" href="/">
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
<q-img
v-if="customLogoUrl"
height="30px"
@@ -72,33 +72,36 @@
</q-badge>
<lnbits-language-dropdown></lnbits-language-dropdown>
<q-btn-dropdown
v-if="g.user || g.isUserAuthorized"
flat
rounded
size="sm"
class="q-pl-sm"
>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<template v-slot:label>
<q-avatar v-if="g.user?.extra?.picture !== ''" size="18px">
<q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
size="18px"
>
<q-img :src="g.user?.extra?.picture"></q-img>
</q-avatar>
<q-avatar v-else icon="account_circle" size="18px"></q-avatar>
</template>
<q-list style="max-width: 200px">
<q-item>
<q-item-section avatar v-if="g.user?.extra?.picture !== ''">
<q-item-section
avatar
v-if="
g.user &&
g.user?.extra?.picture &&
g.user?.extra?.picture !== ''
"
>
<q-avatar size="md">
<img :src="g.user?.extra?.picture" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label
caption
class="ellipsis"
v-text="displayName"
></q-item-label>
<q-item-label caption v-text="displayRole"></q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
@@ -132,6 +135,20 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="g.isUserImpersonated"
@click="stopImpersonation"
rounded
size="sm"
class="q-pl-sm"
color="negative"
icon="face_retouching_off"
label="Stop"
>
<q-tooltip
><span v-text="$t('stop_user_impersonation')"></span
></q-tooltip>
</q-btn>
</q-toolbar>
</q-header>
</template>
@@ -44,6 +44,16 @@
>
<q-tooltip>Write NFC Tag</q-tooltip>
</q-btn>
<q-btn
v-if="print"
flat
dense
class="text-grey"
@click="printQrCode()"
icon="print"
>
<q-tooltip>Print</q-tooltip>
</q-btn>
<q-btn flat dense class="text-grey" icon="download" @click="downloadSVG">
<q-tooltip>Download SVG</q-tooltip>
</q-btn>
@@ -83,9 +83,7 @@
class="text-center"
v-text="$t('export_to_phone_desc')"
></p>
<lnbits-qrcode
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
+2 -2
View File
@@ -120,7 +120,7 @@
class="q-mb-md"
></q-input>
<q-input
v-if="g.user.has_password"
v-if="g.user.hasPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
@@ -941,7 +941,7 @@
v-if="props.row.thumbnail_base64"
target="_blank"
style="color: inherit"
:href="`/api/v1/assets/${props.row.id}/binary`"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-img
:src="
+234 -3
View File
@@ -15,6 +15,7 @@
<q-space></q-space>
<q-input
v-if="$q.screen.gt.sm"
:label="$t('search_extensions')"
:dense="dense"
class="float-right q-pr-xl"
@@ -33,6 +34,14 @@
</q-icon>
</template>
</q-input>
<q-btn
v-else
flat
icon="search"
@click="searchToggle = !searchToggle"
class="q-mr-md"
>
</q-btn>
<q-badge
v-if="g.user.admin && updatableExtensions?.length"
@click="showUpdateAllDialog = true"
@@ -68,6 +77,25 @@
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
</q-tabs>
<div v-if="$q.screen.lt.sm && searchToggle" class="q-pa-sm">
<q-input
:label="$t('search_extensions')"
dense
class=""
v-model="searchTerm"
autofocus
>
<template v-slot:append>
<q-icon
v-if="searchTerm !== ''"
name="close"
@click="searchTerm = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
@@ -135,7 +163,22 @@
v-text="extension.name"
></div>
<div style="justify-content: space-between; display: flex">
<lnbits-extension-rating :rating="0" />
<lnbits-extension-rating
:rating="
formatAvg(
extension.reviewStats
? extension.reviewStats.avg_rating
: 0
)
"
:count="
extension.reviewStats
? extension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(extension)"
/>
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
@@ -970,8 +1013,20 @@
</div>
<div class="col-sm-12 col-md-4 q-pl-sm">
<lnbits-extension-rating
:rating="0"
size="2.5em"
:rating="
formatAvg(
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.avg_rating
: 0
)
"
:count="
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(selectedExtension)"
></lnbits-extension-rating>
<div class="q-mt-md">
<b>
@@ -1029,6 +1084,182 @@
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="reviewsDialog.show" position="top">
<q-card
class="q-pa-md lnbits__dialog-card"
style="width: 900px; max-width: 95vw"
>
<q-card-section class="q-gutter-y-sm">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-text="
(reviewsDialog.extension && reviewsDialog.extension.name) ||
(reviewsDialog.extension && reviewsDialog.extension.id)
"
></div>
<div
class="text-caption text-grey"
v-text="reviewsDialog.extension && reviewsDialog.extension.id"
></div>
</div>
<div class="col-auto">
<lnbits-extension-rating
:rating="
formatAvg(
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.avg_rating
: 0
)
"
:count="
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.review_count
: null
"
></lnbits-extension-rating>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="!reviewsUrl">
<div
class="text-negative"
v-text="$t('reviews_url_not_configured')"
></div>
</q-card-section>
<q-card-section v-else>
<div v-if="reviewsDialog.loading" class="row justify-center q-pa-lg">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
<div v-else>
<div v-if="reviewsDialog.error" class="text-negative q-mb-md">
<span v-text="reviewsDialog.error"></span>
</div>
<q-form @submit="submitReview" class="q-gutter-md">
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<q-input
dense
filled
v-model="reviewsDialog.form.name"
:label="$t('reviews_name')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-rating
v-model="reviewsDialog.form.rating"
max="10"
icon="star_border"
icon-selected="star"
icon-half="star_half"
color="primary"
class="float-right"
></q-rating>
</div>
<div class="col-12 col-md-6">
<q-input
dense
filled
type="textarea"
autogrow
v-model="reviewsDialog.form.comment"
:label="$t('reviews_comment')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-btn
type="submit"
color="primary"
class="float-right"
unelevated
:loading="reviewsDialog.submitting"
:disable="!reviewsDialog.form.rating"
:label="$t('reviews_submit')"
></q-btn>
</div>
</div>
</q-form>
<q-separator class="q-my-md"></q-separator>
<div class="row q-col-gutter-lg">
<div class="col-12">
<div v-if="reviews.length === 0" class="text-grey q-mb-md">
<span v-text="$t('no_reviews')"></span>
</div>
<div v-else>
<q-table
:rows="reviews"
:columns="reviewsTable.columns"
v-model:pagination="reviewsTable.pagination"
@request="getTagReviews"
:loading="reviewsTable.loading"
row-key="name"
:filter="reviewsTable.search"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="name" :props="props">
<span v-text="props.row.name"></span>
</q-td>
<q-td key="comment" :props="props">
<span v-text="props.row.comment"></span>
</q-td>
<q-td key="created_at" :props="props">
<span
v-text="formatReviewDate(props.row.created_at)"
></span>
</q-td>
<q-td key="rating" :props="props">
<q-rating
class="float-right q-pt-xs"
readonly
icon="star_border"
icon-selected="star"
icon-half="star_half"
:model-value="formatAvg(props.row.rating)"
size="sm"
color="secondary"
></q-rating>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</div>
</q-card-section>
<q-separator v-if="reviews.length"></q-separator>
<q-card-section>
<q-btn
@click="reviewsDialog.show = false"
color="grey"
outline
class="float-right"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="paymentDialog.show" position="top">
<q-card class="q-pa-md lnbits__dialog-card">
<q-card-section>
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
<lnbits-qrcode
:value="paymentDialog.invoice"
:options="{width: 800}"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</q-card-section>
<q-card-actions align="between">
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showUpdateAllDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="row">
+14 -1
View File
@@ -656,7 +656,20 @@
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td>
<q-btn
icon="face"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="impersonateUser(props.row.id)"
>
<q-tooltip
><span v-text="$t('start_user_impersonation')"></span
></q-tooltip>
</q-btn>
<span v-text="props.row.username"></span>
</q-td>
<q-td v-text="props.row.email"></q-td>
+2
View File
@@ -20,6 +20,7 @@ from .corelightning import CoreLightningWallet as CLightningWallet
from .corelightningrest import CoreLightningRestWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lightspark import LightsparkSparkWallet
from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
@@ -69,6 +70,7 @@ __all__ = [
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",

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