Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

303 changed files with 22907 additions and 37582 deletions

View file

@ -23,10 +23,3 @@ mypy.ini
package-lock.json package-lock.json
package.json package.json
pytest.ini pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist

View file

@ -18,6 +18,7 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file # https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB" LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
@ -186,6 +187,7 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon" BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet # StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1 STRIKE_API_ENDPOINT=https://api.strike.me/v1
@ -331,3 +333,4 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
###################################### ######################################
###### Logging and Development ####### ###### Logging and Development #######
###################################### ######################################

View file

@ -75,14 +75,7 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
backend-wallet-class: backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with: with:
custom-pytest: "uv run pytest tests/regtest" custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}

View file

@ -62,4 +62,3 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}

View file

@ -1,27 +1,8 @@
name: Build LNbits AppImage name: Build LNbits AppImage
on: on:
workflow_call: release:
inputs: types: [published]
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs: jobs:
build-linux-package: build-linux-package:
@ -94,7 +75,7 @@ jobs:
# Build AppImage # Build AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ inputs.tag_name }} TAG_NAME=${{ github.event.release.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage" APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \ ./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \ --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
@ -112,7 +93,7 @@ jobs:
- name: Upload Linux Release Asset - name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
with: with:
upload_url: ${{ inputs.upload_url }} upload_url: ${{ github.event.release.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }} asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }} asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream asset_content_type: application/octet-stream

View file

@ -40,8 +40,8 @@ jobs:
run: | run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./start-regtest chmod +x ./tests
./start-regtest ./tests
sudo chmod -R a+rwx . sudo chmod -R a+rwx .
- name: Run pytest - name: Run pytest
@ -63,8 +63,6 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001 LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee" LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082 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_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits

View file

@ -10,29 +10,7 @@ permissions:
jobs: 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: docker:
if: github.repository == 'lnbits/lnbits'
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
@ -41,7 +19,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1 - name: Install dependencies for building secp256k1
@ -53,10 +30,3 @@ jobs:
uses: JRubics/poetry-publish@v1.15 uses: JRubics/poetry-publish@v1.15
with: with:
pypi_token: ${{ secrets.PYPI_API_KEY }} 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 }}

View file

@ -13,8 +13,6 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@ -23,17 +21,8 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@ -43,7 +32,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest: docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@ -53,7 +41,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1 - name: Install dependencies for building secp256k1
@ -65,10 +52,3 @@ jobs:
uses: JRubics/poetry-publish@v1.15 uses: JRubics/poetry-publish@v1.15
with: with:
pypi_token: ${{ secrets.PYPI_API_KEY }} 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 }}

View file

@ -23,7 +23,7 @@ repos:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: v3.7.4 rev: v3.6.2
hooks: hooks:
- id: prettier - id: prettier
types_or: [css, javascript, html, json] types_or: [css, javascript, html, json]

View file

@ -1,213 +0,0 @@
# Custom Frontend URL - Simple Redirect Approach
## Overview
This is the **simplest** approach to integrate LNbits with a custom frontend. LNbits handles all authentication (login, register, password reset) and then redirects users to your custom frontend URL instead of `/wallet`.
## How It Works
### Password Reset Flow
1. **Admin generates reset link** → User receives: `https://lnbits.com/?reset_key=...`
2. **User clicks link** → LNbits shows password reset form
3. **User submits new password** → LNbits sets auth cookies
4. **LNbits redirects**`https://myapp.com/` (your custom frontend)
5. **Web-app loads**`checkAuth()` sees valid LNbits cookies → ✅ User is logged in!
### Login Flow
1. **User visits**`https://lnbits.com/`
2. **User logs in** → LNbits validates credentials and sets cookies
3. **LNbits redirects**`https://myapp.com/`
4. **Web-app loads** → ✅ User is logged in!
### Register Flow
1. **User visits**`https://lnbits.com/`
2. **User registers** → LNbits creates account and sets cookies
3. **LNbits redirects**`https://myapp.com/`
4. **Web-app loads** → ✅ User is logged in!
## Configuration
### Environment Variable
```bash
# In .env or environment
export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
```
Or configure through LNbits admin UI: **Settings → Operations → Custom Frontend URL**
### Default Behavior
- **If not set**: Redirects to `/wallet` (default LNbits behavior)
- **If set**: Redirects to your custom frontend URL
## Implementation
### Changes Made
1. **Added setting** (`lnbits/settings.py:282-285`):
```python
class OpsSettings(LNbitsSettings):
lnbits_custom_frontend_url: str | None = Field(
default=None,
description="Custom frontend URL for post-auth redirects"
)
```
2. **Exposed to frontend** (`lnbits/helpers.py:88`):
```python
window_settings = {
# ...
"LNBITS_CUSTOM_FRONTEND_URL": settings.lnbits_custom_frontend_url,
# ...
}
```
3. **Updated redirects** (`lnbits/static/js/index.js`):
- `login()` - line 78
- `register()` - line 56
- `reset()` - line 68
- `loginUsr()` - line 88
All now use:
```javascript
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL || '/wallet'
```
## Advantages
### ✅ Zero Changes to Web-App
Your custom frontend doesn't need to:
- Parse `?reset_key=` from URLs
- Build password reset UI
- Handle password reset API calls
- Manage error states
- Implement validation
### ✅ Auth Cookies Work Automatically
LNbits sets httponly cookies that your web-app automatically sees:
- `cookie_access_token`
- `is_lnbits_user_authorized`
Your existing `auth.checkAuth()` will detect these and log the user in.
### ✅ Simple & Elegant
Only 3 files changed in LNbits, zero changes in web-app.
### ✅ Backwards Compatible
Existing LNbits installations continue to work. Setting is optional.
## Disadvantages
### ⚠️ Brief Branding Inconsistency
Users see LNbits UI briefly during:
- Login form
- Registration form
- Password reset form
Then get redirected to your branded web-app.
**This is usually acceptable** for most use cases, especially for password reset which is infrequent.
## Testing
1. **Set the environment variable**:
```bash
export LNBITS_CUSTOM_FRONTEND_URL=http://localhost:5173
```
2. **Restart LNbits**:
```bash
poetry run lnbits
```
3. **Test Login**:
- Visit `http://localhost:5000/`
- Log in with credentials
- Verify redirect to `http://localhost:5173`
- Verify web-app shows you as logged in
4. **Test Password Reset**:
- Admin generates reset link in users panel
- User clicks link with `?reset_key=...`
- User enters new password
- Verify redirect to custom frontend
- Verify web-app shows you as logged in
## Security
### ✅ Secure
- Auth cookies are httponly (can't be accessed by JavaScript)
- LNbits handles all auth logic
- No sensitive data in URLs except one-time reset keys
- Reset keys expire based on `auth_token_expire_minutes`
### 🔒 HTTPS Required
Always use HTTPS for custom frontend URLs in production:
```bash
export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
```
## Migration
**No database migration required!**
Settings are stored as JSON in `system_settings` table. New fields are automatically included.
## Alternative: Full Custom Frontend Approach
If you need **complete branding consistency** (no LNbits UI shown), you would need to:
1. Build password reset form in web-app
2. Parse `?reset_key=` from URL
3. Add API method to call `/api/v1/auth/reset`
4. Handle validation, errors, loading states
5. Update admin UI to generate links pointing to web-app
This is **significantly more work** for marginal benefit (users see LNbits UI for ~5 seconds during password reset).
## Recommendation
**Use this simple approach** unless you have specific requirements for complete UI consistency. The brief LNbits UI is a small trade-off for the simplicity gained.
## Related Files
- `lnbits/settings.py` - Setting definition
- `lnbits/helpers.py` - Expose to frontend
- `lnbits/static/js/index.js` - Redirect logic
## Example `.env`
```bash
# LNbits Configuration
LNBITS_DATA_FOLDER=./data
LNBITS_DATABASE_URL=sqlite:///./data/database.sqlite3
# Custom Frontend Integration
LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
# Other settings...
```
## How Web-App Benefits
Your web-app at `https://myapp.com` can now:
1. **Receive logged-in users** from LNbits without any code changes
2. **Use existing `auth.checkAuth()`** - it just works
3. **Focus on your features** - don't rebuild auth UI
4. **Trust LNbits security** - it's battle-tested
The auth cookies LNbits sets are valid for your domain if LNbits is on a subdomain (e.g., `api.myapp.com`) or you're using proper CORS configuration.
## Future Enhancement
If you later need the full custom UI approach, all the groundwork is there:
- Setting exists and is configurable
- Just add the web-app UI components
- Update admin panel to generate web-app links
But start with this simple approach first! 🚀

View file

@ -1,7 +1,6 @@
ARG LNBITS_TAG=latest
FROM boltz/boltz-client:latest AS boltz FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/ COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd RUN ls -l /usr/local/bin/boltzd

View file

@ -1,46 +1,38 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <picture >
<picture> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px"> </picture>
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) ![phase: beta](https://img.shields.io/badge/phase-beta-C41E3A) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-08A04B) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" /> ![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # The world's most powerful suite of bitcoin tools.
> Run it for yourself, for your community, or as part of a larger stack. ## Run for yourself, for others, or as part of a stack.
## What is LNbits? LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
LNbits is a lightweight Python server that sits on top of your Lightning funding source. It gives you safe, isolated wallets, a clean API, and an extension system for rapidly adding features - without locking you into a single node implementation. The Inspiration for LNBits came from ideas pioneered by **OpenNode** and **LNPay** — both today work as funding sources for LNbits. LNbits is a Python server that sits on top of any funding source. It can be used as:
## What you can do with LNbits - Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
- Extendable platform for exploring Lightning network functionality via the LNbits extension framework
- Part of a development stack via LNbits API
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
- **Harden app security:** Create per-wallet API keys so individual apps never touch your full balance. LNbits can run on top of almost all Lightning funding sources.
- **Extend functionality fast:** Install extensions to explore and ship Lightning features with minimal code.
- **Build into your stack:** Use the LNbits HTTP API to integrate payments, wallets, and accounting.
- **Cover LNURL flows:** Use LNbits as a reliable fallback wallet for LNURL.
- **Demo in minutes:** Spin up instant wallets for workshops, proofs-of-concept, and user testing.
## Funding sources See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
LNbits runs on top of most Lightning backends. Choose the one you already operate - or swap later without changing your app architecture. Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
- Read the [funding source guide](https://docs.lnbits.org/guide/wallets.html) LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
## Learn more
- Video series on [Youtube](https://www.youtube.com/@lnbits)
- Introduction Video [LNBits V1](https://www.youtube.com/watch?v=PFAHKxvgI9Y&t=19s)
## Running LNbits ## Running LNbits
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup. Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr. See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
## LNbits account system ## LNbits account system
@ -74,15 +66,9 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px"> <img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits ## Tip us
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
[docs]: https://github.com/lnbits/lnbits/wiki [docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg

View file

@ -4,7 +4,6 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png" logo: "/logos/lnbits-full-inverse.png"
search_enabled: true search_enabled: true
url: https://docs.lnbits.org url: https://docs.lnbits.org
markdown: gfm
aux_links: aux_links:
"LNbits on GitHub": "LNbits on GitHub":
- "//github.com/lnbits/lnbits" - "//github.com/lnbits/lnbits"

View file

@ -1,134 +1,78 @@
--- ---
layout: default layout: default
title: Admin UI title: Admin UI
nav_order: 1 nav_order: 4
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> # Admin UI
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) From there on the settings from the database are used.
# LNBits Admin UI # Super User
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides) With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails. Also only the super user can brrrr satoshis to different wallets.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again. The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
a new super user is created.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md) The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" /> We also added a decorator for the API routes to check for super user.
> [!WARNING] There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
> Some settings remain `.env` only. Use **[.env.example](../../.env.example#L3-L87)** as the authoritative reference for those variables.
## What you can do with the Admin UI # Admin Users
- Switch funding sources and other server level settings environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
- Manage who can access LNbits (**[Allowed Users](#allowed-users)**) Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
- Promote or demote Admin Users
- Gate extensions to Admins only or disable them globally
- Adjust balances with credit or debit
- Adjust site customization
> [!NOTE] # Allowed Users
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
## First run and Super User ID environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
On first start with the Admin UI enabled you will be prompted to generate a Super User. Setting this environment variable also disables account creation.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" /> # How to activate
If you need to read it from disk later: ```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits
$ sudo nano .env
```
```bash -> set: `LNBITS_ADMIN_UI=true`
cat /lnbits/data/.super_user
# example Now start LNbits once in the terminal window
```
$ uv run lnbits
```
You can now `cat` the Super User ID:
```
$ cat data/.super_user
123de4bfdddddbbeb48c8bc8382fe123 123de4bfdddddbbeb48c8bc8382fe123
``` ```
> [!WARNING] You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left. After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" /> Here you can design the interface, it has credit/debit to change wallets balances and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
## Enabling or disabling the Admin UI Do not forget
The Admin UI is enabled by default on new installs. To change the state: ```
sudo systemctl start lnbits.service
```
1. Stop LNbits A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
```bash
sudo systemctl stop lnbits.service
```
2. Edit your `.env`
```
cd ~/lnbits
sudo nano .env
```
3. Set one of
```
# Enable Admin UI
LNBITS_ADMIN_UI=true
# Disable Admin UI
LNBITS_ADMIN_UI=false
```
4. Start LNbits
```
sudo systemctl start lnbits.service
```
> [!NOTE]
> With the Admin UI enabled, config is DB-backed and UI-managed settings ignore .env. Disable it to revert to [.env](../../.env.example) as the single source of truth.
## Reset to defaults
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
<img width="1889" height="870" alt="grafik" src="https://github.com/user-attachments/assets/89011b75-a267-44ea-971a-1517968b7af5" />
> [!WARNING]
> Assign your own account first when enabling **Allowed Users** to avoid locking yourself out. If you do get locked out, use your Super User to recover access.
## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)

View file

@ -1,459 +0,0 @@
# Amountless BOLT11 Invoice Support in LNbits
This document provides a comprehensive analysis supporting the implementation of amountless (zero-amount) BOLT11 invoice payments in LNbits, with a focus on the LND REST backend.
## Table of Contents
- [Overview](#overview)
- [What Are Amountless Invoices?](#what-are-amountless-invoices)
- [Use Cases](#use-cases)
- [Industry Analysis: Mobile Wallet Implementations](#industry-analysis-mobile-wallet-implementations)
- [Blixt Wallet (React Native)](#blixt-wallet-react-native)
- [Breez SDK (Flutter)](#breez-sdk-flutter)
- [Zeus Wallet (React Native)](#zeus-wallet-react-native)
- [Common Patterns](#common-patterns)
- [LND API Specification](#lnd-api-specification)
- [SendPaymentRequest Fields](#sendpaymentrequest-fields)
- [Amountless Invoice Handling](#amountless-invoice-handling)
- [Validation Logic](#validation-logic)
- [LNbits Implementation](#lnbits-implementation)
- [Architecture Overview](#architecture-overview)
- [Layer-by-Layer Changes](#layer-by-layer-changes)
- [API Usage](#api-usage)
- [Verification Matrix](#verification-matrix)
- [Security Considerations](#security-considerations)
- [Testing](#testing)
---
## Overview
This implementation adds the ability to pay BOLT11 invoices that don't have an embedded amount by specifying the amount at payment time via the `amount_msat` parameter.
**Key Changes:**
- Added `Feature.amountless_invoice` to wallet base class for capability detection
- Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter
- Implemented amountless invoice support in LND REST and LND gRPC wallets
- Updated payment service layer to validate and pass through `amount_msat`
- Added `amount_msat` field to CreateInvoice API model
---
## What Are Amountless Invoices?
A BOLT11 invoice typically encodes a specific amount to be paid. However, the BOLT11 specification allows for invoices without an amount field, leaving the payment amount to be determined by the payer. These are commonly called:
- **Amountless invoices**
- **Zero-amount invoices**
- **Open invoices**
When decoded, these invoices have `amount_msat = null` or `amount_msat = 0`.
---
## Use Cases
1. **Donations**: Accept any amount the donor wishes to give
2. **Tips**: Allow customers to decide the tip amount
3. **Variable services**: Pay-what-you-want pricing models
4. **LNURL-withdraw**: Some LNURL flows use amountless invoices
5. **NFC payments**: Tap-to-pay scenarios where amount is determined at payment time
---
## Industry Analysis: Mobile Wallet Implementations
To ensure our implementation follows established patterns, we analyzed three major Lightning mobile wallets.
### Blixt Wallet (React Native)
**Detection** (`src/state/Send.ts:185`):
```typescript
if (!paymentRequest.numSatoshis) {
// Invoice is amountless - require user input
}
```
**Amount Injection** (`src/state/Send.ts:203-204`):
```typescript
// Mutate the payment request with user-provided amount
paymentRequest.numSatoshis = payload.amount
```
**UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`):
```typescript
const amountEditable = !paymentRequest.numSatoshis
// Shows editable amount field when invoice has no amount
```
**Key Pattern**: Blixt mutates the payment request object directly before sending to the LND backend.
### Breez SDK (Flutter)
**Detection** (`lib/widgets/payment_request_info_dialog.dart:162`):
```dart
if (widget.invoice.amount == 0) {
// Show amount input field
}
```
**Validation** (`lib/widgets/payment_request_info_dialog.dart:251`):
```dart
var validationResult = acc.validateOutgoingPayment(amountToPay);
if (validationResult.isNotEmpty) {
// Show validation error
}
```
**Key Pattern**: Breez validates the user-entered amount against account balance before payment.
### Zeus Wallet (React Native)
**Detection** (`views/PaymentRequest.tsx:602-603`):
```typescript
const isNoAmountInvoice = !requestAmount || requestAmount === 0
```
**Amount Handling** (`views/Send.tsx:339-341`):
```typescript
if (isNoAmountInvoice) {
// Use amount from user input instead of invoice
amountToSend = userEnteredAmount
}
```
**Key Pattern**: Zeus uses explicit boolean flags (`isNoAmountInvoice`) for clear code intent.
### Common Patterns
All three wallets follow the same logical flow:
| Step | Pattern |
| ----------- | -------------------------------------- |
| 1. Decode | Parse BOLT11 invoice |
| 2. Detect | Check if `amount == 0` or `null` |
| 3. Prompt | Show UI for amount input if amountless |
| 4. Validate | Verify amount > 0 and within balance |
| 5. Inject | Pass amount to payment backend |
| 6. Pay | Execute payment with specified amount |
---
## LND API Specification
The implementation must comply with LND's REST and gRPC API specifications.
### SendPaymentRequest Fields
From `lnrpc/routerrpc/router.proto`:
```protobuf
message SendPaymentRequest {
// Number of satoshis to send.
// The fields amt and amt_msat are mutually exclusive.
int64 amt = 2;
// A bare-bones invoice for a payment within the Lightning Network.
// The amount in the payment request may be zero. In that case it is
// required to set the amt field as well.
string payment_request = 5;
// Number of millisatoshis to send.
// The fields amt and amt_msat are mutually exclusive.
int64 amt_msat = 12;
}
```
**Key Points:**
- `amt` and `amt_msat` are mutually exclusive
- When `payment_request` has zero amount, `amt` or `amt_msat` is **required**
- When `payment_request` has an amount, `amt`/`amt_msat` must **not** be specified
### Amountless Invoice Handling
From `lnrpc/routerrpc/router_backend.go:1028-1045`:
```go
// If the amount was not included in the invoice, then we let
// the payer specify the amount of satoshis they wish to send.
if payReq.MilliSat == nil {
if reqAmt == 0 {
return nil, errors.New("amount must be specified when paying a zero amount invoice")
}
payIntent.Amount = reqAmt
} else {
if reqAmt != 0 {
return nil, errors.New("amount must not be specified when paying a non-zero amount invoice")
}
payIntent.Amount = *payReq.MilliSat
}
```
### Validation Logic
LND's `UnmarshallAmt` function (`lnrpc/marshall_utils.go:57-72`):
```go
func UnmarshallAmt(amtSat, amtMsat int64) (lnwire.MilliSatoshi, error) {
if amtSat != 0 && amtMsat != 0 {
return 0, ErrSatMsatMutualExclusive
}
if amtSat < 0 || amtMsat < 0 {
return 0, ErrNegativeAmt
}
if amtSat != 0 {
return lnwire.NewMSatFromSatoshis(btcutil.Amount(amtSat)), nil
}
return lnwire.MilliSatoshi(amtMsat), nil
}
```
---
## LNbits Implementation
### Architecture Overview
LNbits uses a layered architecture where changes flow from API → Service → Wallet:
```
┌─────────────────────────────────────────────────────────┐
│ API Layer (payment_api.py) │
│ - Receives amount_msat from client │
└─────────────────────┬───────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────┐
│ Service Layer (payments.py) │
│ - Validates amountless invoice + amount_msat │
│ - Checks funding source capability │
│ - Passes amountless_amount_msat through chain │
└─────────────────────┬───────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────┐
│ Wallet Layer (lndrest.py, lndgrpc.py, etc.) │
│ - Adds amt_msat to LND request if provided │
│ - Declares Feature.amountless_invoice capability │
└─────────────────────────────────────────────────────────┘
```
### Layer-by-Layer Changes
#### 1. Base Wallet Class (`lnbits/wallets/base.py`)
**Feature Declaration:**
```python
class Feature(Enum):
nodemanager = "nodemanager"
holdinvoice = "holdinvoice"
amountless_invoice = "amountless_invoice" # NEW
```
**Method Signature:**
```python
@abstractmethod
def pay_invoice(
self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
) -> Coroutine[None, None, PaymentResponse]:
"""
Pay a BOLT11 invoice.
Args:
bolt11: The BOLT11 invoice string
fee_limit_msat: Maximum fee in millisatoshis
amount_msat: Amount to pay in millisatoshis. Required for amountless
invoices on wallets that support Feature.amountless_invoice.
Ignored for invoices that already contain an amount.
"""
pass
```
#### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`)
**Feature Declaration:**
```python
features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice]
```
**Payment Implementation:**
```python
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
) -> PaymentResponse:
req: dict = {
"payment_request": bolt11,
"fee_limit_msat": fee_limit_msat,
"timeout_seconds": 30,
"no_inflight_updates": True,
}
# For amountless invoices, specify the amount to pay
if amount_msat is not None:
req["amt_msat"] = amount_msat
# ... rest of implementation
```
#### 3. Payment Service (`lnbits/core/services/payments.py`)
**Validation:**
```python
def _validate_payment_request(
payment_request: str, max_sat: int | None = None, amount_msat: int | None = None
) -> Bolt11:
invoice = bolt11_decode(payment_request)
if not invoice.amount_msat or invoice.amount_msat <= 0:
# Amountless invoice - check capability and require amount
funding_source = get_funding_source()
if not funding_source.has_feature(Feature.amountless_invoice):
raise PaymentError(
"Amountless invoices not supported by the funding source.",
status="failed",
)
if not amount_msat or amount_msat <= 0:
raise PaymentError(
"Amount required for amountless invoices.",
status="failed",
)
check_amount_msat = amount_msat
else:
check_amount_msat = invoice.amount_msat
# Validate against max payment limit
# ...
```
**Amount Handling:**
```python
# Determine the actual amount to pay
pay_amount_msat = invoice.amount_msat or amount_msat
# Only pass amount to funding source if invoice is amountless
amountless_amount_msat = amount_msat if not invoice.amount_msat else None
```
#### 4. API Layer (`lnbits/core/views/payment_api.py`)
```python
payment = await pay_invoice(
wallet_id=wallet_id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
amount_msat=invoice_data.amount_msat, # NEW
)
```
#### 5. API Model (`lnbits/core/models/payments.py`)
```python
class CreateInvoice(BaseModel):
# ... existing fields
amount_msat: int | None = Query(
None,
ge=1,
description=(
"Amount to pay in millisatoshis. Required for amountless invoices "
"when the funding source supports them."
),
)
```
### API Usage
**Paying an amountless invoice:**
```bash
curl -X POST "https://lnbits.example.com/api/v1/payments" \
-H "X-Api-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{
"out": true,
"bolt11": "lnbc1p...",
"amount_msat": 100000
}'
```
**Response:**
```json
{
"payment_hash": "abc123...",
"checking_id": "abc123...",
"status": "success"
}
```
---
## Verification Matrix
| Requirement | LND Spec | Mobile Wallets | LNbits Implementation |
| --------------------------------- | ------------------------ | ------------------------ | ------------------------------- |
| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` |
| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided |
| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` |
| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` |
| Type | int64 (msat) | varies | int (msat) |
| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` |
---
## Security Considerations
1. **Balance Validation**: The service layer validates that the wallet has sufficient balance for the specified amount before attempting payment.
2. **Maximum Amount**: Amountless payments are still subject to `lnbits_max_outgoing_payment_amount_sats` limits.
3. **Feature Gating**: Only wallets that explicitly declare `Feature.amountless_invoice` support can process amountless payments. This prevents accidental payment failures on unsupported backends.
4. **Input Validation**: The API model enforces `amount_msat >= 1` when provided, preventing zero or negative amounts.
---
## Testing
Two test cases cover the amountless invoice functionality:
### Happy Path
```python
@pytest.mark.anyio
async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from):
"""Test paying an amountless invoice by specifying amount_msat."""
# Create amountless invoice via FakeWallet
# Pay with amount_msat specified
# Verify payment succeeds
```
### Error Case
```python
@pytest.mark.anyio
async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from):
"""Test that paying an amountless invoice without amount_msat fails."""
# Create amountless invoice
# Attempt payment WITHOUT amount_msat
# Verify proper error: "Amount required for amountless invoices"
```
---
## References
- [BOLT11 Specification](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md)
- [LND Router RPC Documentation](https://lightning.engineering/api-docs/api/lnd/router/)
- [LND SendPaymentV2 API](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/)

View file

@ -1,9 +1,3 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install # Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide. Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.

View file

@ -1,9 +1,3 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters ## Defining a route with path parameters
**old:** **old:**

View file

@ -1,85 +1,30 @@
--- # LNbits Funding Sources Comparison Table
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> LNbits can use a number of different Lightning Network funding source.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
LNbits lets you choose **how your wallets are funded** — from fully self-custodial nodes to simple hosted services. You can switch the funding source **without touching your apps, users, or extensions**. That means you can start fast, learn, and later upgrade to more control and privacy when you are ready.
**Why this matters**
- **Flexibility:** Pick the backend that fits your skills and constraints today, change it later with minimal friction.
- **Speed to ship:** Use a hosted option to get live quickly; move to a node when you need more control.
- **Scalability:** Match cost and maintenance to your stage — from hobby to production.
- **Privacy and compliance:** Choose between self-custody and provider-managed options depending on your requirements.
Below is a side-by-side comparison of Lightning funding sources you can use with LNbits.
> [!NOTE]
> “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
## LNbits Lightning Network Funding Sources Comparison Table ## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** | | **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| ------------------------------ | ------------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ | | -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
| **LND (gRPC)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. | | LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| **CoreLightning (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. | | CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| **Phoenixd** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. | | Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| **Nostr Wallet Connect (NWC)** | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. | | Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| **Boltz** | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. | | Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| **LND (REST)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. | | LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| **CoreLightning REST** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. | | CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| **LNbits (another instance)** | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. | | LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| **Alby** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. | | Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| **Breez SDK** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. | | Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| **OpenNode** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. | | OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. | | Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. | | ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. | | Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. | | Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **Eclair (ACINQ)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Connects via API; you run and manage your Eclair node. |
| **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). |
---
### Notes for readers
- These are typical characteristics; your exact experience may vary by configuration and provider policy.
- Pick based on your constraints: compliance (KYC), privacy, ops effort, and time-to-ship.
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)

View file

@ -1,51 +1,18 @@
--- ---
layout: default layout: default
title: Installation title: Basic installation
nav_order: 1 nav_order: 2
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation # Basic installation
> [!NOTE] Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
> **Default DB:** LNbits uses SQLite by default (simple & effective). You can switch to PostgreSQL — see the section below.
## Table of contents ## Option 1: AppImage (LInux)
- [Option 1: AppImage (Linux)](#option-1-appimage-linux) ### AppImage (Linux)
- [Option 2: UV (recommended for developers)](#option-2-uv-recommended-for-developers)
- [Option 2a (Legacy): Poetry — Replaced by UV](#option-2a-legacy-poetry--replaced-by-uv)
- [Option 3: Install script (Debian/Ubuntu)](#option-3-install-script-debianubuntu)
- [Option 4: Nix](#option-4-nix)
- [Option 5: Docker](#option-5-docker)
- [Option 6: Fly.io](#option-6-flyio)
- [Troubleshooting](#troubleshooting)
- [Optional: PostgreSQL database](#optional-postgresql-database)
- [Using LNbits](#using-lnbits)
- [Additional guides](#additional-guides)
- [Update LNbits (all methods)](#update-lnbits-all-methods)
- [SQLite → PostgreSQL migration](#sqlite--postgresql-migration)
- [LNbits as a systemd service](#lnbits-as-a-systemd-service)
- [Reverse proxy with automatic HTTPS (Caddy)](#reverse-proxy-with-automatic-https-caddy)
- [Apache2 reverse proxy over HTTPS](#apache2-reverse-proxy-over-https)
- [Nginx reverse proxy over HTTPS](#nginx-reverse-proxy-over-https)
- [HTTPS without a reverse proxy (self-signed)](#https-without-a-reverse-proxy-self-signed)
- [LNbits on Umbrel behind Tor](#lnbits-on-umbrel-behind-tor)
- [FreeBSD notes](#freebsd-notes)
## Option 1: AppImage (Linux) Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
**Quickstart**
1. Download latest AppImage from [releases](https://github.com/lnbits/lnbits/releases) **or** run:
```sh ```sh
sudo apt-get install jq libfuse2 sudo apt-get install jq libfuse2
@ -54,19 +21,19 @@ chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
``` ```
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage. LNbits will create a folder for db and extension files in the folder the AppImage runs from.
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers) ## Option 2: UV (recommended for developers)
> [!IMPORTANT] It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
> **It is recommended to use the latest version of UV & Make sure you have Python version 3.12 installed.**
### Verify Python ### Install Python 3.12
## Option 2 (recommended): UV
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
### Verify Python version
```sh ```sh
python3 --version python3 --version
@ -79,86 +46,26 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
``` ```
### Install LNbits ### (old) Install Poetry
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
uv sync --all-extras
cp .env.example .env
# Optional: set funding source and other options in .env (e.g., `nano .env`)
```
### Run the server
```sh
uv run lnbits
# To change port/host: uv run lnbits --port 9000 --host 0.0.0.0
# Add --debug to the command above and set DEBUG=true in .env for verbose output
```
### LNbits CLI
```sh
# Useful for superuser ID, updating extensions, etc.
uv run lnbits-cli --help
```
### Update LNbits
```sh
cd lnbits
# Stop LNbits with Ctrl + X or your service manager
# sudo systemctl stop lnbits
# Update code
git pull --rebase
uv sync --all-extras
uv run lnbits
```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_
<details>
<summary><strong>Poetry install and update (legacy workflow)</summary>
This legacy section is preserved for older environments.
**UV is the recommended (and faster) tool** for new installs. Use Poetry only if you have personal preferences or must support an older workflow.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> **It is recommended to use the latest version of Poetry & Make sure you have Python version 3.12 installed.**
### Verify Python version
```sh
python3 --version
```
### Install Poetry
```sh ```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install # If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH" curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
``` ```
### Install LNbits ### install LNbits
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
poetry env use 3.12
git checkout main git checkout main
poetry install --only main uv sync --all-extras
# or poetry
# poetry env use 3.12
# poetry install --only main
cp .env.example .env cp .env.example .env
# Optional: to set funding source amongst other options via the env `nano .env` # Optional: to set funding source amongst other options via the env `nano .env`
``` ```
@ -166,53 +73,50 @@ cp .env.example .env
#### Running the server #### Running the server
```sh ```sh
poetry run lnbits uv run lnbits
# To change port/host: poetry run lnbits --port 9000 --host 0.0.0.0 # To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
# Add --debug to help troubleshooting (also set DEBUG=true in .env)
# or poetry
# poetry run lnbits
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
#### LNbits CLI #### LNbits-cli
```sh ```sh
# A very useful terminal client for getting the superuser ID, updating extensions, etc. # A very useful terminal client for getting the supersuer ID, updating extensions, etc
poetry run lnbits-cli --help uv run lnbits-cli --help
``` ```
#### Updating the server #### Updating the server
```sh ```sh
cd lnbits cd lnbits
# Stop LNbits with Ctrl + X or with your service manager # Stop LNbits with `ctrl + x` or with service manager
# sudo systemctl stop lnbits # sudo systemctl stop lnbits
# Update LNbits # Update LNbits
git pull --rebase git pull --rebase
# Check your Poetry Python version # Check your poetry version with
poetry env list # poetry env list
# If version is less than 3.12, update it: # If version is less 3.12, update it by running
poetry env use python3.12 # poetry env use python3.12
poetry env remove python3.X # poetry env remove python3.9
poetry env list # poetry env list
# Reinstall and start # Run install and start LNbits with
poetry install --only main # poetry install --only main
poetry run lnbits # poetry run lnbits
uv sync --all-extras
uv run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
``` ```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level ## Option 2: Install script (on Debian/Ubuntu)
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
## Option 3: Install script (Debian/Ubuntu)
<details>
<summary><strong>Show install script</strong> (one-line setup)</summary>
```sh ```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh && wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
@ -220,19 +124,11 @@ chmod +x lnbits.sh &&
./lnbits.sh ./lnbits.sh
``` ```
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2). Now visit `0.0.0.0:5000` to make a super-user account.
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `uv run lnbits` (see previous option).
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> ## Option 3: Nix
## Option 4: Nix
<details>
<summary><strong>Show Nix instructions</strong> (flakes, cachix, run)</summary>
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@ -291,36 +187,26 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) ## Option 4: Docker
> **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> Use latest version from Docker Hub.
## Option 5: Docker
<details>
<summary><strong>Show Docker instructions</strong> (official image, volumes, extensions)</summary>
**Use latest image**
```sh ```sh
docker pull lnbits/lnbits docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits \ docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
``` ```
- The LNbits Docker image ships **without any extensions**; by default, any extensions you install are stored **inside the container** and will be **lost** when the container is removed, so you should set `LNBITS_EXTENSIONS_PATH` to a directory thats **mapped to a persistent host volume** so extensions **survive rebuilds/recreates**—for example: The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
Example:
```sh ```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ... docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
``` ```
**Build image yourself** Build the image yourself.
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
@ -328,39 +214,24 @@ cd lnbits
docker build -t lnbits/lnbits . docker build -t lnbits/lnbits .
cp .env.example .env cp .env.example .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits \ docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
``` ```
You can optionally override the install extras for both **Poetry** and **UV** to include optional features during build or setup: You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
- with Poetry, pass extras via the `POETRY_INSTALL_ARGS` Docker build-arg (e.g., to enable the **Breez** funding source: `docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .`);
- with UV, enable extras during environment sync (e.g., locally run `uv sync --extra breez` or `uv sync --all-extras`), and—**if your Dockerfile supports it**—you can mirror the same at build time via a build-arg such as `UV_SYNC_ARGS` (example pattern: `docker build --build-arg UV_SYNC_ARGS="--extra breez" -t lnbits/lnbits .`).
**Enable Breez funding source at build**
```sh ```sh
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits . docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) ## Option 5: Fly.io
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
## Option 6: Fly.io First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
<details> Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
<summary><strong>Deploy LNbits on Fly.io (free tier friendly)</summary>
**Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.** After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
1. Create an account at [Fly.io](https://fly.io).
2. Install the Fly.io CLI ([guide](https://fly.io/docs/getting-started/installing-flyctl/)).
``` ```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
@ -369,9 +240,9 @@ Manually add the directory to your $HOME/.bash_profile (or similar)
export PATH="$FLYCTL_INSTALL/bin:$PATH" export PATH="$FLYCTL_INSTALL/bin:$PATH"
``` ```
3. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
- Once installed, run the following commands. Once installed, run the following commands.
``` ```
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
@ -385,16 +256,9 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings. You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949) Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949) Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
``` ```
... ...
@ -449,49 +313,26 @@ sudo apt install python3.10-dev gcc build-essential
poetry add setuptools wheel poetry add setuptools wheel
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b) ### Optional: PostgreSQL database
>
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Troubleshooting If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
```sh ```sh
sudo apt install pkg-config libffi-dev libpq-dev # on debian/ubuntu 'sudo apt-get -y install postgresql'
# or follow instructions at https://www.postgresql.org/download/linux/
# build essentials (Debian/Ubuntu) # Postgres doesn't have a default password, so we'll create one.
sudo apt install python3.10-dev gcc build-essential
# if secp256k1 build fails and you used poetry
poetry add setuptools wheel
```
</details>
</details>
## Optional: PostgreSQL database
> [!TIP]
> If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and set up a database for LNbits.
```sh
# Debian/Ubuntu: sudo apt-get -y install postgresql
# or see https://www.postgresql.org/download/linux/
# Create a password for the postgres user
sudo -i -u postgres sudo -i -u postgres
psql psql
# in psql # on psql
ALTER USER postgres PASSWORD 'myPassword'; ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
\q \q
# back as postgres user # on postgres user
createdb lnbits createdb lnbits
exit exit
``` ```
**Configure LNbits** You need to edit the `.env` file.
```sh ```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL= # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
@ -502,189 +343,44 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
# Using LNbits # Using LNbits
Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`). Now you can visit your LNbits at http://localhost:5000/.
### Option A — First-run setup in the Browser (UI) Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
1. On the **first start**, LNbits will **prompt you to Setup a SuperUser**. Then you can restart it and it will be using the new settings.
2. After creating it, youll be **redirected to the Admin UI as SuperUser**.
3. In the Admin UI, **set your funding source** (backend wallet) and other preferences.
4. **Restart LNbits** if prompted or after changing critical settings.
> [!IMPORTANT] You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
> Use the **SuperUser only** for initial setup and instance settings (funding source, configuration, Topup).
> For maintenance, create a separate **Admin** account. For everyday usage (payments, wallets, etc.), **do not use the SuperUser** — use admin or regular user accounts instead. Its a bad behaviour.
> Read more about [SuperUser](./super_user.md) and [Admin UI](./admin_ui.md)
### Option B — Configure via `.env` Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
1. Edit your `.env` with preferred settings (funding, base URL, etc.).
2. Set a funding source by configuring:
- `LNBITS_BACKEND_WALLET_CLASS`
- plus the required credentials for your chosen backend (see **[wallets.md](./wallets.md)**).
3. **Restart LNbits** to apply changes.
---
> [!NOTE]
> **Paths overview**
>
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP]
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/
> [!TIP]
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/
# Additional guides # Additional guides
## Update LNbits (all methods) ## SQLite to PostgreSQL migration
> After updating, open **Admin UI → Extensions → “Update All”** to make sure extensions match the core version. If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
<details> There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
<summary><strong>UV (recommended)</strong></summary>
```sh
cd lnbits
git pull --rebase
uv sync --all-extras
# restart (dev)
uv run lnbits
```
</details>
<details>
<summary><strong>Poetry (legacy)</strong></summary>
```sh
cd lnbits
git pull --rebase
# Optional: ensure Python 3.12
poetry env list
poetry env use python3.12
poetry install --only main
# restart (dev)
poetry run lnbits
```
</details>
<details>
<summary><strong>AppImage</strong></summary>
Download the latest AppImage from Releases and replace your old file **in the same directory** to keep the `./data` folder (DB, extensions).
</details>
<details>
<summary><strong>Install script (Debian/Ubuntu)</strong></summary>
```sh
# If you installed via lnbits.sh:
cd lnbits
git pull --rebase
# then use your chosen runner (UV recommended)
uv sync --all-extras
uv run lnbits
```
</details>
<details>
<summary><strong>Nix</strong></summary>
```sh
cd lnbits
git pull --rebase
nix build
# restart
nix run
```
</details>
<details>
<summary><strong>Docker (official image)</strong></summary>
```sh
docker pull lnbits/lnbits
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Docker (build yourself)</strong></summary>
```sh
cd lnbits
git pull --rebase
docker build -t lnbits/lnbits .
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Fly.io</strong></summary>
```sh
# If using Dockerfile in repo (recommended)
cd lnbits
git pull --rebase
fly deploy
# Logs & shell if needed
fly logs
fly ssh console
```
</details>
## SQLite → PostgreSQL migration
> [!TIP]
> If you run on SQLite and plan to scale, migrate to Postgres.
```sh ```sh
# STOP LNbits # STOP LNbits
# Edit .env with Postgres URL # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit # save and exit
# START then STOP LNbits once to apply schema # START LNbits
# STOP LNbits
uv run python tools/conv.py uv run python tools/conv.py
# or # or
make migration make migration
``` ```
- Launch LNbits again and verify. Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd service ## LNbits as a systemd service
Create `/etc/systemd/system/lnbits.service`: Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
``` ```
# Systemd unit for lnbits # Systemd unit for lnbits
@ -692,14 +388,17 @@ Create `/etc/systemd/system/lnbits.service`:
[Unit] [Unit]
Description=LNbits Description=LNbits
# Optional: start after your backend # you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service #Wants=lnd.service
#After=lnd.service #After=lnd.service
[Service] [Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits WorkingDirectory=/home/lnbits/lnbits
# Find uv path via `which uv` # same here. run `which uv` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/uv run lnbits ExecStart=/home/lnbits/.local/bin/uv run lnbits
# replace with the user that you're running lnbits on
User=lnbits User=lnbits
Restart=always Restart=always
TimeoutSec=120 TimeoutSec=120
@ -710,23 +409,33 @@ Environment=PYTHONUNBUFFERED=1
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
Enable & start: Save the file and run the following commands:
```sh ```sh
sudo systemctl enable lnbits.service sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service sudo systemctl start lnbits.service
``` ```
## Reverse proxy with automatic HTTPS (Caddy) ## Reverse proxy with automatic HTTPS using Caddy
Point your domain A-record to your server IP. Install Caddy: [Caddy install guide](https://caddyserver.com/docs/install#debian-ubuntu-raspbian) Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
```sh Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
Install Caddy on the server
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
```
sudo caddy stop sudo caddy stop
```
Create a Caddyfile
```
sudo nano Caddyfile sudo nano Caddyfile
``` ```
Add: Assuming your LNbits is running on port `5000` add:
``` ```
yourdomain.com { yourdomain.com {
@ -736,21 +445,28 @@ yourdomain.com {
} }
``` ```
Save (Ctrl+X) and start: Save and exit `CTRL + x`
```sh ```
sudo caddy start sudo caddy start
``` ```
## Apache2 reverse proxy over HTTPS ## Running behind an Apache2 reverse proxy over HTTPS
Install Apache2 and enable Apache2 mods:
```sh ```sh
apt-get install apache2 certbot apt-get install apache2 certbot
a2enmod headers ssl proxy proxy_http a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
``` ```
Create `/etc/apache2/sites-enabled/lnbits.conf`: Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
```sh ```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
@ -777,20 +493,27 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
EOF EOF
``` ```
Restart: Restart Apache2:
```sh ```sh
service apache2 restart service apache2 restart
``` ```
## Nginx reverse proxy over HTTPS ## Running behind an Nginx reverse proxy over HTTPS
Install nginx:
```sh ```sh
apt-get install nginx certbot apt-get install nginx certbot
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --nginx --agree-tos -d lnbits.org certbot certonly --nginx --agree-tos -d lnbits.org
``` ```
Create `/etc/nginx/sites-enabled/lnbits.org`: Create an nginx vhost at `/etc/nginx/sites-enabled/lnbits.org`:
```sh ```sh
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
@ -824,22 +547,23 @@ server {
EOF EOF
``` ```
Restart: Restart nginx:
```sh ```sh
service nginx restart service nginx restart
``` ```
--- ## Using https without reverse proxy
## HTTPS without a reverse proxy (self-signed) The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
Create a self-signed cert (useful for local/dev). Browsers wont trust it by default. We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
### Install mkcert #### Install mkcert
- Install instructions: [mkcert README](https://github.com/FiloSottile/mkcert) You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
- Ubuntu example:
Install mkcert on Ubuntu:
```sh ```sh
sudo apt install libnss3-tools sudo apt install libnss3-tools
@ -848,47 +572,70 @@ chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
``` ```
### Create certificate #### Create certificate
**OpenSSL** To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh ```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
``` ```
**mkcert** (alternative) This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh ```sh
# include your local IP (e.g., 192.x.x.x) if needed # add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1 mkcert localhost 127.0.0.1 ::1
``` ```
**Run with certs** You can then pass the certificate files to uvicorn when you start LNbits:
```sh ```sh
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
``` ```
## LNbits on Umbrel behind Tor ## LNbits running on Umbrel behind Tor
See this community [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604). If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
## FreeBSD notes ## Docker installation
Issue with secp256k1 0.14.0 on FreeBSD (thanks @GitKalle): To install using docker you first need to build the docker image as:
1. Install `py311-secp256k1` with `pkg install py311-secp256k1`. ```
2. Change version in `pyproject.toml` from `0.14.0` to `0.13.2`. git clone https://github.com/lnbits/lnbits.git
3. Rewrite `poetry.lock` with `poetry lock`. cd lnbits
4. Follow install instructions with Poetry. docker build -t lnbits/lnbits .
```
--- You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
## Powered by LNbits ```
cp <lnbits_repo>/.env.example .env
```
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. and change the configuration in `.env` as required.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com) Then create the data directory
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) ```
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) mkdir data
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
```
Finally you can access your lnbits on your machine at port 5000.
### FreeBSD notes
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
3. Rewrite `poetry.lock` file with command `poetry lock`
4. Follow install instruction with Poetry

View file

@ -1,133 +0,0 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
**Table of Contents**
- [What is the Super User?](#what-is-the-super-user)
- [When is the Super User created?](#when-is-the-super-user-created)
- [Disabeling the Admin UI](#disabeling-the-admin-ui)
- [Super User identity and storage](#super-user-identity-and-storage)
- [Security model since v1](#security-model-since-v1)
- [Admin vs Super User](#admin-vs-super-user)
- [Operational guidance](#operational-guidance)
- [Additional guides](#additional-guides)
<details>
<summary><strong>TLDR</strong></summary>
- **No Admin UI → No Super User.** The Super User (SU) exists only when `LNBITS_ADMIN_UI=true`.
- **Why SU exists:** SU can do a few high impact actions regular admins cannot, like **changing the funding source**, **restarting the server from the UI**, and **crediting or debiting accounts**.
- **Login changes since v1:** Logging in by **user ID** for SU and admins is **disabled**. On first visit after enabling the Admin UI you will be prompted to set a **username and password** for the SU.
- **Trust model:** Admins and the SU share about **99 percent of the same powers**, but the SU is the one trusted with funding source control and cannot be demoted by regular admins.
</details>
## What is the Super User?
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
**Key SU capabilities**
- **Change the funding source** for the instance
- **Restart the LNbits server** from the web UI
- **Credit or debit accounts** for operational corrections
> Note
> These are separated from regular admin tasks on purpose. It helps maintain least privilege and reduces the chance of accidental or malicious changes.
## Admin vs Super User
| Capability | Admin | Super User |
| ------------------------ | ---------- | ---------- |
| View Admin UI | If enabled | If enabled |
| Change funding source | — | ✓ |
| Credit or debit accounts | — | ✓ |
| Restart server from UI | — | ✓ |
| Manage users and wallets | ✓ | ✓ |
| Instance-level settings | ✓ | ✓ |
| Manage notifications | ✓ | ✓ |
| Exchange rates | ✓ | ✓ |
| View all Payments | ✓ | ✓ |
**Why both roles?**
In many teams the person running the server prefers to **delegate day-to-day admin work** while keeping funding and final authority safe. Admins can do almost everything; the SU retains the last few high risk powers.
## When is the Super User created?
- The SU is created **only** when you enable the Admin UI: `LNBITS_ADMIN_UI=true`.
- If the Admin UI is **disabled**, there is **no SU** and all SU-only UI is hidden.
## Disabeling the Admin UI
> [!IMPORTANT]
> Read the [Admin UI guide](./admin_ui.md) before Disabeling. You are turning on a management surface; do it deliberately.
Set the environment variable in your deployment:
```bash
# .env
LNBITS_ADMIN_UI=false
```
## Super User identity and storage
LNbits stores the **Super User ID** at:
```
/lnbits/data/.super_user
```
- Back this up along with the rest of `/lnbits/data` as part of your secure backup routine.
- **Changing who is the SU** can only be done by someone with **CLI access to the host OS** where LNbits runs. **Regular admins cannot revoke or replace the SU in the Admin UI.**
## Security model since v1
- **User-ID logins are disabled** for SU and admin roles.
- **Credentialed login is required:** set a **username and password** for the SU at first run of the Admin UI.
- **SU secrecy:** Regular users and admins **cannot discover the SU user ID** through normal UI flows.
## Operational guidance
These are practical tips for running a safe and friendly instance.
- It is normal to **delegate admin** duties to trusted people. Admins have about **99 percent** of SU powers for day-to-day work.
- Keep the **SU** reserved for the person legally or operationally responsible for the **funding source**.
- Use admin roles for regular day-to-day management and keep the SU for reserved SU tasks only.
## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)

View file

@ -1,102 +0,0 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:**
[Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) •
[Regular User](#regular-user--everyday-use) •
[Best Practices](#best-practices) •
[Additional Guides](#additional-guides)
---
## Roles at a Glance
| Capability | **Super User** (owner) | **Admin** (manager) | **Regular User** (end user) |
| -------------------------------- | :--------------------: | :-----------------: | :-------------------------: |
| Change **funding source** | ✅ | ❌ | ❌ |
| Credit/Debit any wallet | ✅ | ❌ | ❌ |
| Manage Admins & Users | ✅ | ✅ | ❌ |
| Enable/disable extensions | ✅ | ✅ | ❌ |
| Use wallets & allowed extensions | ✅ | ✅ | ✅ |
> **Plain talk:** **Super User** = Owner • **Admin** = Trusted manager • **Regular User** = End user
## Role Snapshots
### Super User — Master Control
For initial setup and rare, high-impact changes.
- Configure **server-level settings** (e.g., funding source).
- **Credit/Debit** any wallet.
- Create and manage initial **Admin(s)**.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Admin — Day-to-Day Manager
For running the service without touching the most sensitive knobs.
- Manage **Users**, **Admins**, and **Extensions** in the Admin UI.
- Adjust security-related settings (e.g., `rate_limiter`, `ip_blocker`).
- Handle operations settings (e.g., `service_fee`, `invoice_expiry`).
- Build brand design in **Site Customization**.
- Update user accounts.
**Typical tasks:** onboarding users, enabling extensions, tidying wallets, reviewing activity.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Regular User — Everyday Use
For using LNbits, not administering it.
- Access **personal wallets** and **allowed extensions**.
- No server/admin privileges.
**Typical tasks:** receive and send payments, use enabled extensions.
## Best Practices
- **Minimize risk:** Reserve **Super User** for rare, sensitive actions (funding source, debit/credit). Use **Admin** for daily operations.
- **Keep access tidy:** Review your Admin list occasionally; remove unused accounts.
- **Change management:** Test risky changes (like funding) in a staging setup first.
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems—fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)

View file

@ -4,367 +4,177 @@ title: Backend wallets
nav_order: 3 nav_order: 3
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets # Backend wallets
**LNbits is modular**: You can switch the funding source (backend wallet) **without changing anything else** in your setup. Keep your extensions, apps, users, and config as-is — just point LNbits to a different backend via environment variables. LNbits can run on top of many Lightning Network funding sources with more being added regularly.
**What stays the same when you switch backends** A backend wallet can be configured using the following LNbits environment variables:
- Your LNbits setup and extensions You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
- Your API keys and endpoints
- Your server and deployment setup
A backend wallet is selected and configured entirely through LNbits environment variables. See the options and variables below, and compare them here: [Funding-Source-Table.md](funding-sources-table.md)
> [!NOTE]
> **Terminology:** “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
## Funding Sources
## Funding Sources
| | | |
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------- |
| [CLNRest (runes)](#clnrest-runes) | [LND (REST)](#lnd-rest) | [OpenNode](#opennode) |
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
| [Fake Wallet](#fake-wallet) | | |
---
<a id="clnrest-runes"></a>
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune)) ### 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) Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables** - `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
- `LNBITS_BACKEND_WALLET_CLASS`: `CLNRestWallet`
- `CLNREST_URL`: `https://127.0.0.1:3010` - `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the file) - `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the file) - `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'` - `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08) - `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
**Create runes (copy/paste)** ### CoreLightning
```bash - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
# Read-only: funds, pays, invoices, info, summary, and invoice listener - `CORELIGHTNING_RPC`: /file/path/lightning-rpc
lightning-cli createrune \
restrictions='[["method=listfunds","method=listpays","method=listinvoices","method=getinfo","method=summary","method=waitanyinvoice"]]' \
| jq -r .rune
```
```bash ### CoreLightning REST
# Invoice: max 1,000,001 msat, label must start with "LNbits", 60 req/min
lightning-cli createrune \
restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' \
| jq -r .rune
```
```bash This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
# Pay: bolt11 amount < 1001 (msat), label must start with "LNbits", 1 req/min
lightning-cli createrune \
restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
| jq -r .rune
```
```bash - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
# Renepay: invstring amount < 1001 (msat), label must start with "LNbits", 1 req/min - `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
lightning-cli createrune \ - `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \ - `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
| jq -r .rune
```
Set the resulting values into: ### Spark (Core Lightning)
- `CLNREST_READONLY_RUNE` - `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
- `CLNREST_INVOICE_RUNE` - `SPARK_URL`: http://10.147.17.230:9737/rpc
- `CLNREST_PAY_RUNE` - `SPARK_TOKEN`: secret_access_key
- `CLNREST_RENEPAY_RUNE`
## CoreLightning ### LND (REST)
**Required env vars** - `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningWallet` or
- `CORELIGHTNING_RPC`: `/file/path/lightning-rpc`
## CoreLightning REST - `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST) ### LND (gRPC)
**Required env vars** - `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningRestWallet` You can also use an AES-encrypted macaroon (more info) instead by using
- `CORELIGHTNING_REST_URL`: `http://127.0.0.1:8185/`
- `CORELIGHTNING_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
- `CORELIGHTNING_REST_CERT`: `/home/lightning/clnrest/tls.cert`
## Spark (Core Lightning) - `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
**Required env vars** To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkWallet` ### LNbits
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key`
## LND (REST) - `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
- `LNBITS_KEY`: lnbitsAdminKey
**Required env vars** ### LNPay
- `LNBITS_BACKEND_WALLET_CLASS`: `LndRestWallet` For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
- `LND_REST_ENDPOINT`: `http://10.147.17.230:8080/`
- `LND_REST_CERT`: `/file/path/tls.cert`
- `LND_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
or: - `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
- `LND_REST_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn` ### OpenNode
## LND (gRPC) For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
**Required env vars** - `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
- `LNBITS_BACKEND_WALLET_CLASS`: `LndWallet` ### Blink
- `LND_GRPC_ENDPOINT`: `ip_address`
- `LND_GRPC_PORT`: `port`
- `LND_GRPC_CERT`: `/file/path/tls.cert`
- `LND_GRPC_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
You can also use an AES-encrypted macaroon instead: For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LND_GRPC_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn` - `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
To encrypt your macaroon: ### Alby
```bash For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
uv run lnbits-cli encrypt macaroon
```
## LNbits - `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
**Required env vars** ### Boltz
- `LNBITS_BACKEND_WALLET_CLASS`: `LNbitsWallet` This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
- `LNBITS_ENDPOINT`: for example `https://lnbits.com` You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
- `LNBITS_KEY`: `lnbitsAdminKey` Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
## LNPay - `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook). ### ZBD
**Required env vars** For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
- `LNBITS_BACKEND_WALLET_CLASS`: `LNPayWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
- `LNPAY_API_ENDPOINT`: `https://api.lnpay.co/v1/` - `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `LNPAY_API_KEY`: `sak_apiKey` - `ZBD_API_KEY`: ZBDApiKey
- `LNPAY_WALLET_KEY`: `waka_apiKey`
## OpenNode ### Phoenixd
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required. For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
**Required env vars** - `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
- `LNBITS_BACKEND_WALLET_CLASS`: `OpenNodeWallet` ### Breez SDK
- `OPENNODE_API_ENDPOINT`: `https://api.opennode.com/`
- `OPENNODE_KEY`: `opennodeAdminApiKey`
## Blink A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required. - `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
You can generate a Blink API key at [https://dashboard.blink.sv](https://dashboard.blink.sv). More info: [https://dev.blink.sv/api/auth#create-an-api-key](https://dev.blink.sv/api/auth#create-an-api-key) ### Breez Liquid SDK
**Required env vars** This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
- `LNBITS_BACKEND_WALLET_CLASS`: `BlinkWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
- `BLINK_API_ENDPOINT`: `https://api.blink.sv/graphql` - `BREEZ_LIQUID_SEED`: ...
- `BLINK_WS_ENDPOINT`: `wss://ws.blink.sv/graphql`
- `BLINK_TOKEN`: `BlinkToken`
## Alby Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required. - `LNBITS_RESERVE_FEE_MIN`: ...
- `LNBITS_RESERVE_FEE_PERCENT`: ...
Generate an Alby access token here: [https://getalby.com/developer/access_tokens/new](https://getalby.com/developer/access_tokens/new) ### Cliche Wallet
**Required env vars** - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
- `LNBITS_BACKEND_WALLET_CLASS`: `AlbyWallet` ### Nostr Wallet Connect (NWC)
- `ALBY_API_ENDPOINT`: `https://api.getalby.com/`
- `ALBY_ACCESS_TOKEN`: `AlbyAccessToken`
## Boltz To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
This connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles Lightning payments through submarine swaps on the Liquid network. You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
You can run the daemon in standalone mode via `standalone = True` in the config or `boltzd --standalone`. Create a Liquid wallet with: - `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
```bash
boltzcli wallet create lnbits lbtc
```
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BoltzWallet`
- `BOLTZ_CLIENT_ENDPOINT`: `127.0.0.1:9002`
- `BOLTZ_CLIENT_MACAROON`: `/home/bob/.boltz/macaroons/admin.macaroon` or Base64/Hex
- `BOLTZ_CLIENT_CERT`: `/home/bob/.boltz/tls.cert` or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: `lnbits`
## ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
Generate a ZBD API key here: [https://zbd.dev/docs/dashboard/projects/api](https://zbd.dev/docs/dashboard/projects/api)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `ZBDWallet`
- `ZBD_API_ENDPOINT`: `https://api.zebedee.io/v0/`
- `ZBD_API_KEY`: `ZBDApiKey`
## Phoenixd
For the invoice to work you must have a publicly accessible URL in your LNbits.
You can get a phoenixd API key from `~/.phoenix/phoenix.conf`. See the phoenixd documentation for details.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `PhoenixdWallet`
- `PHOENIXD_API_ENDPOINT`: `http://localhost:9740/`
- `PHOENIXD_API_PASSWORD`: `PhoenixdApiPassword`
## Eclair (ACINQ)
<a id="eclair-acinq"></a>
Connect to an existing Eclair node so your backend handles invoices and payments.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `EclairWallet`
- `ECLAIR_URL`: `http://127.0.0.1:8283`
- `ECLAIR_PASSWORD`: `eclairpw`
## Fake Wallet
<a id="fake-wallet"></a>
A testing-only backend that mints accounting units inside LNbits accounting (no real sats).
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `FakeWallet`
- `FAKE_SECRET`: `ToTheMoon1`
- `FAKE_UNIT`: `sats`
## LN.tips
<a id="lntips"></a>
As the initinal LN.tips bot is no longer active the code still exists and is widly used. Connect one of custodial services as your backend to create and pay Lightning invoices through their API or selfhost this service and run it as funding source.
Resources: https://github.com/massmux/SatsMobiBot
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNTipsWallet`
- `LNTIPS_API_ENDPOINT`: `https://ln.tips`
- `LNTIPS_API_KEY`: `LNTIPS_ADMIN_KEY`
## Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can register a new node with Greenlight. If the Greenlight node already exists, neither is required.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezSdkWallet`
- `BREEZ_API_KEY`: `...`
- `BREEZ_GREENLIGHT_SEED`: `...`
- `BREEZ_GREENLIGHT_INVITE_CODE`: `...`
- `BREEZ_GREENLIGHT_DEVICE_KEY`: `/path/to/breezsdk/device.pem` or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: `/path/to/breezsdk/device.crt` or Base64/Hex
## Breez Liquid SDK
This uses the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage Lightning payments via submarine swaps on the Liquid network. Provide a mnemonic seed phrase (for example, generate one with a Liquid wallet like [Blockstream Green](https://blockstream.com/green/)) and set it in the environment or admin UI.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezLiquidSdkWallet`
- `BREEZ_LIQUID_SEED`: `...`
Fees apply for each submarine swap. You may need to increase the reserve fee under **Settings → Funding** or via:
- `LNBITS_RESERVE_FEE_MIN`: `...`
- `LNBITS_RESERVE_FEE_PERCENT`: `...`
## Cliche Wallet
**Required env vars**
- `CLICHE_ENDPOINT`: `ws://127.0.0.1:12000`
## Nostr Wallet Connect (NWC)
To use NWC as a funding source you need a pairing URL (pairing secret) from an NWC provider. See providers here:
[https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets)
Configure in the admin UI or via env vars:
- `LNBITS_BACKEND_WALLET_CLASS`: `NWCWallet`
- `NWC_PAIRING_URL`: `nostr+walletconnect://...your...pairing...secret...`
<a id="strike"></a>
## 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.
Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.strike.me/strike-oauth-connect/)
**Integration endpoints**
- `STRIKE_API_ENDPOINT`: `https://api.strike.me/v1`
- `STRIKE_API_KEY`: `YOUR_STRIKE_API_KEY`
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)

View file

@ -1,134 +0,0 @@
{
"featured": [
"satmachineclient",
"castle"
],
"extensions": [
{
"id": "satmachineadmin",
"repo": "https://git.atitlan.io/aiolabs/satmachineadmin",
"name": "Satoshi Machine Admin",
"min_lnbits_version": "1.4.0",
"version": "0.0.3",
"short_description": "Admin Dashboard for Satoshi Machine",
"icon": "https://git.atitlan.io/aiolabs/satmachineadmin/raw/branch/main/static/image/aio.png",
"archive": "https://git.atitlan.io/aiolabs/satmachineadmin/archive/v0.0.3.zip",
"hash": "0cf520ee62037298320d8c0caa6f15d89858447d826af53472cb369023eda46d"
},
{
"id": "satmachineadmin",
"repo": "https://git.atitlan.io/aiolabs/satmachineadmin",
"name": "Satoshi Machine Admin",
"version": "0.0.4",
"short_description": "Admin Dashboard for Satoshi Machine",
"icon": "https://git.atitlan.io/aiolabs/satmachineadmin/raw/branch/main/static/image/aio.png",
"archive": "https://git.atitlan.io/aiolabs/satmachineadmin/archive/v0.0.4.zip",
"hash": "f5a6e33e6379984964cd0a4b25225770c4356c5c8a62c8e811b712775a180615"
},
{
"id": "satmachineclient",
"repo": "https://git.atitlan.io/aiolabs/satmachineclient",
"name": "Satoshi Machine Client",
"version": "0.0.1",
"short_description": "Client Dashboard for Satoshi Machine",
"icon": "https://git.atitlan.io/aiolabs/satmachineclient/raw/branch/main/static/image/aio.png",
"archive": "https://git.atitlan.io/aiolabs/satmachineclient/archive/v0.0.1.zip",
"hash": "05cf74b0f8a39953ad9c063c40a983d6223ebbfa906f5598b6ebe2c58eccfd9a"
},
{
"id": "events",
"repo": "https://git.atitlan.io/aiolabs/events",
"name": "AIO Events",
"version": "0.0.1",
"short_description": "AIO fork of aiolabs/events",
"icon": "https://git.atitlan.io/aiolabs/events/raw/branch/main/static/image/events.png",
"archive": "https://git.atitlan.io/aiolabs/events/archive/v0.0.1.zip",
"hash": "410ac9c340551aabe88e0dc7393ed30dc7f4703f1345af5e6b02b0ebc5337d52"
},
{
"id": "events",
"repo": "https://git.atitlan.io/aiolabs/events",
"name": "AIO Events",
"version": "0.0.2",
"short_description": "AIO fork of aiolabs/events",
"icon": "https://git.atitlan.io/aiolabs/events/raw/branch/main/static/image/events.png",
"archive": "https://git.atitlan.io/aiolabs/events/archive/v0.0.2.zip",
"hash": "4ac140b97b164f81c482cc19da07a79e961685ae146dc89cba6d12e7d5e80716"
},
{
"id": "nostrclient",
"repo": "https://git.atitlan.io/aiolabs/nostrclient",
"name": "AIO nostrclient",
"version": "0.0.1",
"short_description": "AIO fork of aiolabs/nostrclient",
"icon": "https://git.atitlan.io/aiolabs/nostrclient/raw/branch/main/static/images/nostr-bitcoin.png",
"archive": "https://git.atitlan.io/aiolabs/nostrclient/archive/v0.0.1.zip",
"hash": "7f0b1861243a10d16d41d17bc6e64616d61c6b299a86f40dc1a0f9cfc7f9725a"
},
{
"id": "nostrmarket",
"repo": "https://git.atitlan.io/aiolabs/nostrmarket",
"name": "AIO nostrmarket",
"version": "0.0.1",
"short_description": "AIO fork of aiolabs/nostrmarket",
"icon": "https://git.atitlan.io/aiolabs/nostrmarket/raw/branch/main/static/image/bitcoin-shop.png",
"archive": "https://git.atitlan.io/aiolabs/nostrmarket/archive/v0.0.1.zip",
"hash": "7599485232c600c00896caac15c0fdec07aef26d2cce95c9b2df85b9807bf4f3"
},
{
"id": "nostrrelay",
"repo": "https://git.atitlan.io/aiolabs/nostrrelay",
"name": "AIO Nostrrelay",
"version": "0.0.2",
"short_description": "AIO fork of aiolabs/nostrrelay",
"icon": "https://git.atitlan.io/aiolabs/nostrrelay/raw/branch/main/static/image/nostrrelay.png",
"archive": "https://git.atitlan.io/aiolabs/nostrrelay/archive/v0.0.2.zip",
"hash": "6e353358314b740679e55e2ff98c7024a97c6df8bf27d1784e8cee448407bebd"
},
{
"id": "castle",
"repo": "https://git.atitlan.io/aiolabs/castle",
"name": "Castle",
"version": "0.0.4",
"short_description": "Castle Accounting",
"icon": "https://git.atitlan.io/aiolabs/castle/raw/branch/main/static/image/castle.png",
"archive": "https://git.atitlan.io/aiolabs/castle/archive/v0.0.4.zip",
"hash": "a5169440c47265b6bb8cf1cc2466a6b743bab00d16e18bd3a97956fc8ea3a069"
},
{
"id": "castle",
"repo": "https://git.atitlan.io/aiolabs/castle",
"name": "Castle",
"version": "0.0.5",
"short_description": "Castle Accounting",
"icon": "https://git.atitlan.io/aiolabs/castle/raw/branch/main/static/image/castle.png",
"archive": "https://git.atitlan.io/aiolabs/castle/archive/v0.0.5.zip",
"hash": "4c7c3683632cbdd6b1e810e5d1020ad6f03189414507c62d4dc9217813983c3c"
},
{
"id": "lnurlp",
"repo": "https://github.com/lnbits/lnurlp",
"name": "Pay Links",
"version": "1.1.3",
"min_lnbits_version": "1.3.0",
"short_description": "Make reusable LNURL pay links",
"icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png",
"details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json",
"archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.1.3.zip",
"hash": "913b6880fd824e801f05ae50ed6f57817c9a580f1ed1b02b435823d5754e1b98",
"max_lnbits_version": "1.4.0"
},
{
"id": "lnurlp",
"repo": "https://github.com/lnbits/lnurlp",
"name": "Pay Links",
"version": "1.2.0",
"min_lnbits_version": "1.4.0",
"short_description": "Make reusable LNURL pay links",
"icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png",
"details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json",
"archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.2.0.zip",
"hash": "d3872eb5f65b9e962fc201d6784f94f3fd576284582b980805142a2b3f62d8e8"
}
]
}

View file

@ -145,7 +145,6 @@
nixpkgs.overlays = [ self.overlays.${system}.default ]; nixpkgs.overlays = [ self.overlays.${system}.default ];
}; };
checks = checks = { };
import ./nix/tests { inherit pkgs; flake = self; };
}); });
} }

View file

@ -1,6 +1,5 @@
from .core.services import create_invoice, pay_invoice from .core.services import create_invoice, pay_invoice
from .decorators import ( from .decorators import (
check_account_exists,
check_admin, check_admin,
check_super_user, check_super_user,
check_user_exists, check_user_exists,
@ -12,7 +11,6 @@ from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
"InvoiceError", "InvoiceError",
"PaymentError", "PaymentError",
"check_account_exists",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",

View file

@ -297,7 +297,6 @@ async def create_user(username: str, password: str):
account.hash_password(password) account.hash_password(password)
user = await create_user_account_no_ckeck(account) user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'") click.echo(f"User '{user.username}' created. Id: '{user.id}'")
click.echo(f"Nostr public key: {account.pubkey}")
@users.command("cleanup-accounts") @users.command("cleanup-accounts")

View file

@ -3,7 +3,6 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db from .db import core_app_extra, db
from .views.admin_api import admin_router from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router from .views.callback_api import callback_router
@ -45,7 +44,6 @@ def init_core_routers(app: FastAPI):
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router) app.include_router(fiat_router)
app.include_router(lnurl_router) app.include_router(lnurl_router)

View file

@ -1,108 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)

View file

@ -16,12 +16,11 @@ async def get_audit_entries(
conn: Connection | None = None, conn: Connection | None = None,
) -> Page[AuditEntry]: ) -> Page[AuditEntry]:
return await (conn or db).fetch_page( return await (conn or db).fetch_page(
"SELECT * FROM audit", "SELECT * from audit",
[], [],
{}, {},
filters=filters, filters=filters,
model=AuditEntry, model=AuditEntry,
table_name="audit",
) )

View file

@ -33,12 +33,13 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment( async def get_standalone_payment(
checking_id_or_hash: str, checking_id_or_hash: str,
conn: Connection | None = None,
incoming: bool | None = False, incoming: bool | None = False,
wallet_id: str | None = None, wallet_id: str | None = None,
conn: Connection | None = None,
) -> Payment | None: ) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash" clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = { values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash, "checking_id": checking_id_or_hash,
"hash": checking_id_or_hash, "hash": checking_id_or_hash,
} }
@ -46,10 +47,6 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0" clause = f"({clause}) AND amount > 0"
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id" clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
@ -69,16 +66,13 @@ async def get_standalone_payment(
async def get_wallet_payment( async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None: ) -> Payment | None:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone( payment = await (conn or db).fetchone(
""" """
SELECT * SELECT *
FROM apipayments FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash WHERE wallet_id = :wallet AND payment_hash = :hash
""", """,
{"wallet": wallet.source_wallet_id, "hash": payment_hash}, {"wallet": wallet_id, "hash": payment_hash},
Payment, Payment,
) )
return payment return payment
@ -124,6 +118,7 @@ async def get_payments_paginated( # noqa: C901
Filters payments to be returned by: Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming. - complete | pending | failed | outgoing | incoming.
""" """
values: dict[str, Any] = { values: dict[str, Any] = {
"time": since, "time": since,
} }
@ -133,11 +128,7 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}") clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) values["wallet_id"] = wallet_id
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id") clause.append("wallet_id = :wallet_id")
elif user_id: elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn) only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
@ -180,7 +171,6 @@ async def get_payments_paginated( # noqa: C901
values, values,
filters=filters, filters=filters,
model=Payment, model=Payment,
table_name="apipayments",
) )
@ -292,7 +282,6 @@ async def create_payment(
fee=-abs(data.fee), fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [],
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
@ -325,14 +314,13 @@ async def get_payments_history(
wallet_id: str | None = None, wallet_id: str | None = None,
group: DateTrunc = "day", group: DateTrunc = "day",
filters: Filters | None = None, filters: Filters | None = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]: ) -> list[PaymentHistoryPoint]:
if not filters: if not filters:
filters = Filters() filters = Filters()
date_trunc = db.datetime_grouping(group) date_trunc = db.datetime_grouping(group)
values: dict[str, Any] = { values = {
"wallet_id": wallet_id, "wallet_id": wallet_id,
} }
# count outgoing payments if they are still pending # count outgoing payments if they are still pending
@ -361,13 +349,13 @@ async def get_payments_history(
filters.values(values), filters.values(values),
) )
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if wallet:
return [] balance = wallet.balance_msat
balance = wallet.balance_msat else:
values["wallet_id"] = wallet.source_wallet_id raise ValueError("Unknown wallet")
else: else:
balance = await get_total_balance(conn=conn) balance = await get_total_balance()
# since we dont know the balance at the starting point, # since we dont know the balance at the starting point,
# we take the current balance and walk backwards # we take the current balance and walk backwards

View file

@ -4,7 +4,7 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import create_wallet, get_wallets from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
@ -23,39 +23,16 @@ async def create_account(
) -> Account: ) -> Account:
if account: if account:
account.validate_fields() account.validate_fields()
# If account doesn't have Nostr keys, generate them
# Exception: Nostr login users who already have a public key but no private key
# should not get a new private key generated - they use their existing Nostr identity
if not account.pubkey and not account.prvkey:
from lnbits.utils.nostr import generate_keypair
nostr_private_key, nostr_public_key = generate_keypair()
account.pubkey = nostr_public_key
account.prvkey = nostr_private_key
elif account.pubkey and not account.prvkey:
# This is a Nostr login user - they already have a public key from their existing identity
# We don't generate a private key for them as they use their own Nostr client
# The chat system will need to handle this case by requesting the private key from the user
pass
else: else:
# Generate Nostr keypair for new account
from lnbits.utils.nostr import generate_keypair
nostr_private_key, nostr_public_key = generate_keypair()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
account = Account( account = Account(id=uuid4().hex, created_at=now, updated_at=now)
id=uuid4().hex,
created_at=now,
updated_at=now,
pubkey=nostr_public_key, # Use Nostr public key as the pubkey
prvkey=nostr_private_key,
)
await (conn or db).insert("accounts", account) await (conn or db).insert("accounts", account)
return account return account
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await db.update("accounts", account)
return account return account
@ -91,7 +68,6 @@ async def get_accounts(
accounts.username, accounts.username,
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.prvkey,
accounts.external_id, accounts.external_id,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
@ -113,7 +89,6 @@ async def get_accounts(
filters=filters, filters=filters,
model=AccountOverview, model=AccountOverview,
group_by=["accounts.id"], group_by=["accounts.id"],
table_name="accounts",
) )
@ -195,29 +170,22 @@ async def get_account_by_username_or_email(
async def get_user(user_id: str, conn: Connection | None = None) -> User | None: async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: account = await get_account(user_id, conn)
account = await get_account(user_id, conn=conn) if not account:
if not account: return None
return None return await get_user_from_account(account, conn)
return await get_user_from_account(account, conn=conn)
async def get_user_from_account( async def get_user_from_account(
account: Account, conn: Connection | None = None account: Account, conn: Connection | None = None
) -> User | None: ) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: extensions = await get_user_active_extensions_ids(account.id, conn)
extensions = await get_user_active_extensions_ids(account.id, conn=conn) wallets = await get_wallets(account.id, False, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User( return User(
id=account.id, id=account.id,
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, # This is now the Nostr public key pubkey=account.pubkey,
external_id=account.external_id, external_id=account.external_id,
extra=account.extra, extra=account.extra,
created_at=account.created_at, created_at=account.created_at,
@ -231,11 +199,9 @@ async def get_user_from_account(
) )
async def update_user_access_control_list( async def update_user_access_control_list(user_acls: UserAcls):
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc) user_acls.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", user_acls) await db.update("accounts", user_acls)
async def get_user_access_control_lists( async def get_user_access_control_lists(

View file

@ -3,10 +3,9 @@ from time import time
from uuid import uuid4 from uuid import uuid4
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet from ..models import Wallet
@ -15,22 +14,17 @@ async def create_wallet(
*, *,
user_id: str, user_id: str,
wallet_name: str | None = None, wallet_name: str | None = None,
wallet_type: WalletType = WalletType.LIGHTNING,
shared_wallet_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Wallet: ) -> Wallet:
wallet_id = uuid4().hex wallet_id = uuid4().hex
wallet = Wallet( wallet = Wallet(
id=wallet_id, id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name, name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
user=user_id, user=user_id,
adminkey=uuid4().hex, adminkey=uuid4().hex,
inkey=uuid4().hex, inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD", currency=settings.lnbits_default_accounting_currency or "USD",
) )
await (conn or db).insert("wallets", wallet) await (conn or db).insert("wallets", wallet)
return wallet return wallet
@ -52,11 +46,6 @@ async def delete_wallet(
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
now = int(time()) 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( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
@ -114,8 +103,8 @@ async def delete_unused_wallets(
) )
async def get_standalone_wallet( async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> Wallet | None: ) -> Wallet | None:
query = """ query = """
SELECT *, COALESCE(( SELECT *, COALESCE((
@ -132,23 +121,8 @@ async def get_standalone_wallet(
) )
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets( async def get_wallets(
user_id: str, user_id: str, deleted: bool | None = None, conn: Connection | None = None
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]: ) -> list[Wallet]:
query = """ query = """
SELECT *, COALESCE(( SELECT *, COALESCE((
@ -158,20 +132,12 @@ async def get_wallets(
""" """
if deleted is not None: if deleted is not None:
query += " AND deleted = :deleted " query += " AND deleted = :deleted "
if wallet_type is not None: return await (conn or db).fetchall(
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query, query,
{ {"user": user_id, "deleted": deleted},
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet, Wallet,
) )
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated( async def get_wallets_paginated(
user_id: str, user_id: str,
@ -183,7 +149,7 @@ async def get_wallets_paginated(
deleted = False deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """] where: list[str] = [""" "user" = :user AND deleted = :deleted """]
wallets = await (conn or db).fetch_page( return await (conn or db).fetch_page(
""" """
SELECT *, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
@ -193,27 +159,20 @@ async def get_wallets_paginated(
values={"user": user_id, "deleted": deleted}, values={"user": user_id, "deleted": deleted},
filters=filters, filters=filters,
model=Wallet, model=Wallet,
table_name="wallets",
) )
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids( async def get_wallets_ids(
user_id: str, deleted: bool | None = False, conn: Connection | None = None user_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> list[str]: ) -> list[str]:
query = """SELECT * FROM wallets WHERE "user" = :user""" query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None: if deleted is not None:
query += " AND deleted = :deleted " query += "AND deleted = :deleted"
wallets = await (conn or db).fetchall( result: list[dict] = await (conn or db).fetchall(
query, query,
{"user": user_id, "deleted": deleted}, {"user": user_id, "deleted": deleted},
Wallet,
) )
return [row["id"] for row in result]
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count(): async def get_wallets_count():
@ -226,7 +185,7 @@ async def get_wallet_for_key(
key: str, key: str,
conn: Connection | None = None, conn: Connection | None = None,
) -> Wallet | None: ) -> Wallet | None:
wallet = await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT *, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
@ -237,57 +196,6 @@ async def get_wallet_for_key(
{"key": key}, {"key": key},
Wallet, Wallet,
) )
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None): async def get_total_balance(conn: Connection | None = None):

View file

@ -7,7 +7,6 @@ from uuid import UUID
from loguru import logger from loguru import logger
from lnbits.core import migrations as core_migrations from lnbits.core import migrations as core_migrations
from lnbits.core import migrations_fork as core_migrations_fork
from lnbits.core.crud import ( from lnbits.core.crud import (
get_db_versions, get_db_versions,
get_installed_extensions, get_installed_extensions,
@ -101,13 +100,6 @@ async def migrate_databases():
) )
await run_migration(conn, core_migrations, "core", core_version) await run_migration(conn, core_migrations, "core", core_version)
# Run fork-specific migrations separately to avoid version conflicts
core_fork_version = next(
(v for v in current_versions if v.db == "core_fork"),
DbVersion(db="core_fork", version=0),
)
await run_migration(conn, core_migrations_fork, "core_fork", core_fork_version)
# here is the first place we can be sure that the # here is the first place we can be sure that the
# `installed_extensions` table has been created # `installed_extensions` table has been created
await load_disabled_extension_list() await load_disabled_extension_list()

View file

@ -743,108 +743,3 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
""" """
) )
async def m035_add_wallet_type_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
"""
)
async def m036_add_shared_wallet_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
"""
)
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
"""
)
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)

View file

@ -1,24 +0,0 @@
"""
Fork-specific database migrations.
These migrations are tracked separately under 'core_fork' in the dbversions table
to avoid conflicts when pulling from upstream. Use sequential numbering starting
from m001.
IMPORTANT: DO NOT MERGE THESE MIGRATIONS UPSTREAM
"""
from sqlalchemy.exc import OperationalError
from lnbits.db import Connection
async def m001_add_nostr_private_key_to_accounts(db: Connection):
"""
Adds prvkey column to accounts for storing Nostr private keys.
FORK MIGRATION - DO NOT MERGE UPSTREAM
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN prvkey TEXT")
except OperationalError:
pass

View file

@ -46,7 +46,7 @@ from .users import (
UserAcls, UserAcls,
UserExtra, UserExtra,
) )
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __all__ = [
@ -57,6 +57,7 @@ __all__ = [
"AuditEntry", "AuditEntry",
"AuditFilters", "AuditFilters",
"BalanceDelta", "BalanceDelta",
"BaseWallet",
"Callback", "Callback",
"CancelInvoice", "CancelInvoice",
"ConversionData", "ConversionData",
@ -98,7 +99,6 @@ __all__ = [
"UserAcls", "UserAcls",
"UserExtra", "UserExtra",
"Wallet", "Wallet",
"WalletInfo",
"WalletTypeInfo", "WalletTypeInfo",
"WebPushSubscription", "WebPushSubscription",
] ]

View file

@ -1,37 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None

View file

@ -6,7 +6,6 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -21,7 +20,6 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
@ -608,37 +606,6 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls, cls,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = [] extension_list: list[InstallableExtension] = []

View file

@ -59,7 +59,6 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel): class Payment(BaseModel):
@ -69,7 +68,7 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
payment_request: str | None = Field(default=None, no_database=True) # payment_request: str | None
fiat_provider: str | None = None fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
@ -82,16 +81,8 @@ class Payment(BaseModel):
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {} extra: dict = {}
def __init__(self, **data):
super().__init__(**data)
if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"]
else:
self.payment_request = self.bolt11
@property @property
def pending(self) -> bool: def pending(self) -> bool:
return self.status == PaymentState.PENDING.value return self.status == PaymentState.PENDING.value
@ -186,25 +177,9 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = [ __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = [ __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
]
status: str | None status: str | None
tag: str | None tag: str | None
@ -216,7 +191,6 @@ class PaymentFilters(FilterModel):
preimage: str | None preimage: str | None
payment_hash: str | None payment_hash: str | None
wallet_id: str | None wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel): class PaymentDataPoint(BaseModel):
@ -291,16 +265,6 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = []
# For paying amountless invoices (out=true only)
amount_msat: int | None = Query(
None,
ge=1,
description=(
"Amount to pay in millisatoshis. Required for amountless invoices "
"when the funding source supports them."
),
)
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@ -349,7 +313,3 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []

View file

@ -12,7 +12,6 @@ from lnbits.db import FilterModel
from lnbits.helpers import ( from lnbits.helpers import (
is_valid_email_address, is_valid_email_address,
is_valid_external_id, is_valid_external_id,
is_valid_label,
is_valid_pubkey, is_valid_pubkey,
is_valid_username, is_valid_username,
) )
@ -30,21 +29,6 @@ class UserNotifications(BaseModel):
incoming_payments_sats: int = 0 incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: str
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: bool | None = False email_verified: bool | None = False
first_name: str | None = None first_name: str | None = None
@ -62,55 +46,6 @@ class UserExtra(BaseModel):
notifications: UserNotifications = UserNotifications() notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel): class EndpointAccess(BaseModel):
path: str path: str
@ -172,20 +107,12 @@ class UserAcls(BaseModel):
return None return None
class AccountId(BaseModel): class Account(BaseModel):
id: str id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
external_id: str | None = None # for external account linking external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: str | None = None pubkey: str | None = None
prvkey: str | None = None # Nostr private key for user
email: str | None = None email: str | None = None
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
@ -198,27 +125,10 @@ class Account(AccountId):
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
# NOTE: I tried this in the past and it resulted in unexpected behavior
# all accounts were suddenly showing up in the peers list, however, if
# they did not have a key-pair, they were being assigned one on the fly.
# Something about fetching the users was causing this code to trigger.
#
#
# # Generate Nostr keypair if not already provided
# if not self.pubkey or not self.prvkey:
# from lnbits.utils.nostr import generate_keypair
# nostr_public_key, nostr_private_key = generate_keypair()
# self.pubkey = nostr_public_key
# self.prvkey = nostr_private_key
#
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id) self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
salt = gensalt() salt = gensalt()
@ -250,8 +160,6 @@ class Account(AccountId):
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account): class AccountOverview(Account):
transaction_count: int | None = 0 transaction_count: int | None = 0
@ -262,7 +170,7 @@ class AccountOverview(Account):
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
__search_fields__ = [ __search_fields__ = [
"id", "user",
"email", "email",
"username", "username",
"pubkey", "pubkey",
@ -270,18 +178,17 @@ class AccountFilters(FilterModel):
"wallet_id", "wallet_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
"id", "balance_msat",
"email", "email",
"username", "username",
"pubkey", "transaction_count",
"external_id", "wallet_count",
"created_at", "last_payment",
"updated_at",
] ]
id: str | None = None
username: str | None = None
email: str | None = None email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None external_id: str | None = None
wallet_id: str | None = None wallet_id: str | None = None
@ -293,7 +200,7 @@ class User(BaseModel):
updated_at: datetime updated_at: datetime
email: str | None = None email: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None # This is now the Nostr public key pubkey: str | None = None
external_id: str | None = None # for external account linking external_id: str | None = None # for external account linking
extensions: list[str] = [] extensions: list[str] = []
wallets: list[Wallet] = [] wallets: list[Wallet] = []

View file

@ -4,14 +4,16 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings from lnbits.settings import settings
class WalletInfo(BaseModel): class BaseWallet(BaseModel):
id: str id: str
name: str name: str
adminkey: str adminkey: str
@ -19,109 +21,18 @@ class WalletInfo(BaseModel):
balance_msat: int balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel): class WalletExtra(BaseModel):
icon: str = "flash_on" icon: str = "flash_on"
color: str = "primary" color: str = "primary"
pinned: bool = False pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class BaseWallet(BaseModel): class Wallet(BaseModel):
id: str id: str
user: str user: str
wallet_type: str = WalletType.LIGHTNING.value name: str
adminkey: str adminkey: str
inkey: str inkey: str
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@ -129,65 +40,6 @@ class Wallet(BaseWallet):
balance_msat: int = Field(default=0, no_database=True) balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks() stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property @property
def balance(self) -> int: def balance(self) -> int:
@ -198,23 +50,16 @@ class Wallet(BaseWallet):
return self.balance_msat - settings.fee_reserve(self.balance_msat) return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property @property
def is_lightning_wallet(self) -> bool: def lnurlwithdraw_full(self) -> str:
return self.wallet_type == WalletType.LIGHTNING.value url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
@property return lnurl_encode(url)
def is_lightning_shared_wallet(self) -> bool: except Exception:
return self.wallet_type == WalletType.LIGHTNING_SHARED.value return ""
def _validate_data(self):
if self.is_lightning_shared_wallet:
if not self.shared_wallet_id:
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel): class CreateWallet(BaseModel):
name: str | None = None name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum): class KeyType(Enum):
@ -233,12 +78,6 @@ class WalletTypeInfo:
wallet: Wallet wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel): class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"] __search_fields__ = ["id", "name", "currency"]

View file

@ -1,56 +0,0 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
data=contents,
)
await create_asset(asset)
return asset

View file

@ -16,7 +16,6 @@ from lnbits.core.crud.extensions import (
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@ -150,9 +149,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions( async def get_valid_extensions(
include_deactivated: bool | None = True, conn: Connection | None = None include_deactivated: bool | None = True,
) -> list[Extension]: ) -> list[Extension]:
installed_extensions = await get_installed_extensions(conn=conn) installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions] valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated: if include_deactivated:

View file

@ -1,13 +1,11 @@
import hashlib import hashlib
import hmac import hmac
import json
import time import time
import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment from lnbits.core.crud.payments import create_payment, get_standalone_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection from lnbits.db import Connection
@ -29,130 +27,6 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)
async def _credit_fiat_service_fee_wallet( async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Connection | None = None payment: Payment, conn: Connection | None = None
): ):
@ -230,3 +104,90 @@ async def _debit_fiat_service_faucet_wallet(
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
) )
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)

View file

@ -70,8 +70,7 @@ async def check_balance_delta_changed():
if settings.latest_balance_delta_sats is None: if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
return return
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats) if status.delta_sats != settings.latest_balance_delta_sats:
if delta_change >= settings.notification_balance_delta_threshold_sats:
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.balance_delta, NotificationType.balance_delta,
{ {

View file

@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import smtplib import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
@ -17,7 +16,6 @@ from lnbits.core.crud import (
mark_webhook_sent, mark_webhook_sent,
) )
from lnbits.core.crud.users import get_user from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Payment, Wallet from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import ( from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
@ -242,10 +240,6 @@ async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
check_callback_url(payment.webhook) check_callback_url(payment.webhook)
except ValueError as exc:
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
try:
r = await client.post(payment.webhook, json=payment.json(), timeout=40) r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status() r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code)) await mark_webhook_sent(payment.payment_hash, str(r.status_code))
@ -263,12 +257,6 @@ async def dispatch_webhook(payment: Payment):
async def send_payment_notification(wallet: Wallet, payment: Payment): async def send_payment_notification(wallet: Wallet, payment: Payment):
try: try:
await send_ws_payment_notification(wallet, payment) await send_ws_payment_notification(wallet, payment)
for shared in wallet.extra.shared_with:
if not shared.shared_with_wallet_id:
continue
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
if shared_wallet and shared_wallet.can_view_payments:
await send_ws_payment_notification(shared_wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}") logger.error(f"Error sending websocket payment notification {e!s}")
try: try:
@ -287,13 +275,6 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
logger.error(f"Error dispatching webhook: {e!s}") logger.error(f"Error dispatching webhook: {e!s}")
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
try:
create_task(send_payment_notification(wallet, payment))
except Exception as e:
logger.warning(f"Error sending payment notification: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send(wallet.inkey, payment.json())
@ -400,7 +381,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.watchdog_check: if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta: if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_threshold_sats > 0 return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop: if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status: if message_type == NotificationType.server_status:

View file

@ -24,7 +24,6 @@ from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_pre
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
Feature,
InvoiceResponse, InvoiceResponse,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
@ -48,7 +47,7 @@ from ..models import (
PaymentState, PaymentState,
Wallet, Wallet,
) )
from .notifications import send_payment_notification_in_background from .notifications import send_payment_notification
payment_lock = asyncio.Lock() payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {} wallets_payments_lock: dict[str, asyncio.Lock] = {}
@ -62,80 +61,37 @@ async def pay_invoice(
extra: dict | None = None, extra: dict | None = None,
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
amount_msat: int | None = None,
) -> Payment: ) -> Payment:
"""
Pay a BOLT11 invoice.
Args:
wallet_id: The wallet to pay from
payment_request: The BOLT11 invoice string
max_sat: Maximum amount allowed in satoshis
extra: Extra metadata to store with the payment
description: Payment description/memo
tag: Payment tag (usually extension name)
labels: Payment labels
conn: Optional database connection to reuse
amount_msat: Amount to pay in millisatoshis. Required for amountless
invoices when the funding source supports Feature.amountless_invoice.
Returns:
The created Payment object
"""
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat, amount_msat) invoice = _validate_payment_request(payment_request, max_sat)
if not invoice.amount_msat:
# Determine the actual amount to pay raise ValueError("Missig invoice amount.")
# For amountless invoices, use the provided amount_msat
pay_amount_msat = invoice.amount_msat or amount_msat
if not pay_amount_msat:
raise ValueError("Missing invoice amount.")
# For amountless invoices, we need to pass the amount to the funding source
# Only pass amount if the invoice is amountless
amountless_amount_msat = amount_msat if not invoice.amount_msat else None
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
wallet = await _check_wallet_for_payment( amount_msat = invoice.amount_msat
wallet_id, tag, pay_amount_msat, new_conn wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
)
if not wallet.can_send_payments:
raise PaymentError(
"Wallet does not have permission to pay invoices.",
status="failed",
)
if await is_internal_status_success(invoice.payment_hash, new_conn): if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed") raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts( _, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
pay_amount_msat / 1000, wallet, extra=extra
)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount_msat=-pay_amount_msat, amount_msat=-amount_msat,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels,
) )
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
payment = await _pay_invoice( await _credit_service_fee_wallet(wallet, payment, new_conn)
wallet.source_wallet_id,
create_payment_model,
amountless_amount_msat=amountless_amount_msat,
conn=new_conn,
)
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
return payment return payment
@ -154,7 +110,7 @@ async def create_payment_request(
async def create_fiat_invoice( async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
) -> Payment: ):
fiat_provider_name = invoice_data.fiat_provider fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name: if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.") raise ValueError("Fiat provider is required for fiat invoices.")
@ -234,22 +190,19 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
# do not save memo if description_hash or unhashed_description is set # do not save memo if description_hash or unhashed_description is set
memo = "" memo = ""
async with db.connect() as conn: payment = await create_invoice(
payment = await create_invoice( wallet_id=wallet_id,
wallet_id=wallet_id, amount=data.amount,
amount=data.amount, memo=memo,
memo=memo, currency=data.unit,
currency=data.unit, description_hash=description_hash,
description_hash=description_hash, unhashed_description=unhashed_description,
unhashed_description=unhashed_description, expiry=data.expiry,
expiry=data.expiry, extra=data.extra,
extra=data.extra, webhook=data.webhook,
webhook=data.webhook, internal=data.internal,
internal=data.internal, payment_hash=data.payment_hash,
payment_hash=data.payment_hash, )
labels=data.labels,
conn=conn,
)
if data.lnurl_withdraw: if data.lnurl_withdraw:
try: try:
@ -288,7 +241,6 @@ async def create_invoice(
webhook: str | None = None, webhook: str | None = None,
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@ -298,12 +250,6 @@ async def create_invoice(
if not user_wallet: if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed") raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
if not user_wallet.can_receive_payments:
raise InvoiceError(
"Wallet does not have permission to create invoices.",
status="failed",
)
invoice_memo = None if description_hash else memo[:640] invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only # use the fake wallet if the invoice is for internal use only
@ -362,7 +308,7 @@ async def create_invoice(
invoice = bolt11_decode(invoice_response.payment_request) invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=user_wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=invoice_response.payment_request, bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage, preimage=invoice_response.preimage,
@ -372,7 +318,6 @@ async def create_invoice(
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels,
) )
payment = await create_payment( payment = await create_payment(
@ -394,15 +339,13 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment) await update_pending_payment(payment)
async def update_pending_payment( async def update_pending_payment(payment: Payment) -> Payment:
payment: Payment, conn: Connection | None = None
) -> Payment:
status = await payment.check_status() status = await payment.check_status()
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status)
return payment return payment
@ -513,7 +456,7 @@ async def update_wallet_balance(
await create_payment( await create_payment(
checking_id=f"internal_{payment_hash}", checking_id=f"internal_{payment_hash}",
data=CreatePayment( data=CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
bolt11=bolt11, bolt11=bolt11,
payment_hash=payment_hash, payment_hash=payment_hash,
amount_msat=amount * 1000, amount_msat=amount * 1000,
@ -532,7 +475,7 @@ async def update_wallet_balance(
raise ValueError("Balance change failed, amount exceeds maximum balance.") raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice( payment = await create_invoice(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
amount=amount, amount=amount,
memo="Admin credit", memo="Admin credit",
internal=True, internal=True,
@ -705,7 +648,6 @@ async def get_payments_daily_stats(
async def _pay_invoice( async def _pay_invoice(
wallet_id: str, wallet_id: str,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
amountless_amount_msat: int | None = None,
conn: Connection | None = None, conn: Connection | None = None,
): ):
async with payment_lock: async with payment_lock:
@ -722,9 +664,7 @@ async def _pay_invoice(
payment = await _pay_internal_invoice(wallet, create_payment_model, conn) payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment: if not payment:
payment = await _pay_external_invoice( payment = await _pay_external_invoice(wallet, create_payment_model, conn)
wallet, create_payment_model, amountless_amount_msat, conn
)
return payment return payment
@ -742,7 +682,6 @@ async def _pay_internal_invoice(
internal_payment = await check_internal( internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn create_payment_model.payment_hash, conn=conn
) )
if not internal_payment: if not internal_payment:
return None return None
@ -751,7 +690,6 @@ async def _pay_internal_invoice(
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn internal_payment.checking_id, incoming=True, conn=conn
) )
if not internal_invoice: if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed") raise PaymentError("Internal payment not found.", status="failed")
@ -773,7 +711,6 @@ async def _pay_internal_invoice(
internal_id = f"internal_{create_payment_model.payment_hash}" internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment( payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
data=create_payment_model, data=create_payment_model,
@ -788,7 +725,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
send_payment_notification_in_background(wallet, payment) await send_payment_notification(wallet, payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@ -802,7 +739,6 @@ async def _pay_internal_invoice(
async def _pay_external_invoice( async def _pay_external_invoice(
wallet: Wallet, wallet: Wallet,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
amountless_amount_msat: int | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
checking_id = create_payment_model.payment_hash checking_id = create_payment_model.payment_hash
@ -833,9 +769,7 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
task = create_task( task = create_task(
_fundingsource_pay_invoice( _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
checking_id, payment.bolt11, fee_reserve_msat, amountless_amount_msat
)
) )
# make sure a hold invoice or deferred payment is not blocking the server # make sure a hold invoice or deferred payment is not blocking the server
@ -864,7 +798,7 @@ async def _pay_external_invoice(
payment = await update_payment_success_status( payment = await update_payment_success_status(
payment, payment_response, conn=conn payment, payment_response, conn=conn
) )
send_payment_notification_in_background(wallet, payment) await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}") logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id payment.checking_id = payment_response.checking_id
@ -886,15 +820,12 @@ async def update_payment_success_status(
async def _fundingsource_pay_invoice( async def _fundingsource_pay_invoice(
checking_id: str, checking_id: str, bolt11: str, fee_reserve_msat: int
bolt11: str,
fee_reserve_msat: int,
amountless_amount_msat: int | None = None,
) -> PaymentResponse: ) -> PaymentResponse:
logger.debug(f"fundingsource: sending payment {checking_id}") logger.debug(f"fundingsource: sending payment {checking_id}")
funding_source = get_funding_source() funding_source = get_funding_source()
payment_response: PaymentResponse = await funding_source.pay_invoice( payment_response: PaymentResponse = await funding_source.pay_invoice(
bolt11, fee_reserve_msat, amountless_amount_msat bolt11, fee_reserve_msat
) )
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}") logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
return payment_response return payment_response
@ -948,48 +879,21 @@ async def _check_wallet_for_payment(
def _validate_payment_request( def _validate_payment_request(
payment_request: str, max_sat: int | None = None, amount_msat: int | None = None payment_request: str, max_sat: int | None = None
) -> Bolt11: ) -> Bolt11:
"""
Validate a BOLT11 payment request.
Args:
payment_request: The BOLT11 invoice string
max_sat: Maximum amount allowed in satoshis
amount_msat: Amount to pay for amountless invoices (in millisatoshis)
Returns:
Decoded Bolt11 invoice object
"""
try: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
except Exception as exc: except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
# Check if this is an amountless invoice if not invoice.amount_msat or not invoice.amount_msat > 0:
if not invoice.amount_msat or invoice.amount_msat <= 0: raise PaymentError("Amountless invoices not supported.", status="failed")
# Amountless invoice - check if funding source supports it and amount provided
funding_source = get_funding_source()
if not funding_source.has_feature(Feature.amountless_invoice):
raise PaymentError(
"Amountless invoices not supported by the funding source.",
status="failed",
)
if not amount_msat or amount_msat <= 0:
raise PaymentError(
"Amount required for amountless invoices.",
status="failed",
)
# Use provided amount for max_sat check
check_amount_msat = amount_msat
else:
check_amount_msat = invoice.amount_msat
max_sat = max_sat or settings.lnbits_max_outgoing_payment_amount_sats max_sat = max_sat or settings.lnbits_max_outgoing_payment_amount_sats
max_sat = min(max_sat, settings.lnbits_max_outgoing_payment_amount_sats) max_sat = min(max_sat, settings.lnbits_max_outgoing_payment_amount_sats)
if check_amount_msat > max_sat * 1000: if invoice.amount_msat > max_sat * 1000:
raise PaymentError( raise PaymentError(
f"Invoice amount {check_amount_msat // 1000} sats is too high. " f"Invoice amount {invoice.amount_msat // 1000} sats is too high. "
f"Max allowed: {max_sat} sats.", f"Max allowed: {max_sat} sats.",
status="failed", status="failed",
) )
@ -1006,7 +910,7 @@ async def _credit_service_fee_wallet(
memo = f""" memo = f"""
Service fee for payment of {abs(payment.sat)} sats. Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.source_wallet_id}).""" Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,

View file

@ -1,13 +1,9 @@
import json
import time
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from loguru import logger from loguru import logger
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension from lnbits.core.models.extensions import UserExtension
from lnbits.db import Connection
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
SuperSettings, SuperSettings,
@ -52,59 +48,37 @@ async def create_user_account_no_ckeck(
account: Account | None = None, account: Account | None = None,
wallet_name: str | None = None, wallet_name: str | None = None,
default_exts: list[str] | None = None, default_exts: list[str] | None = None,
conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
if account:
account.validate_fields()
if account.username and await get_account_by_username(
account.username, conn=conn
):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email, conn=conn): if account:
raise ValueError("Email already exists.") account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.pubkey and await get_account_by_pubkey( if account.email and await get_account_by_email(account.email):
account.pubkey, conn=conn raise ValueError("Email already exists.")
):
raise ValueError("Pubkey already exists.")
if not account.id: if account.pubkey and await get_account_by_pubkey(account.pubkey):
account.id = uuid4().hex raise ValueError("Pubkey already exists.")
account = await create_account(account, conn=conn) if not account.id:
wallet = await create_wallet( account.id = uuid4().hex
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions account = await create_account(account)
for ext_id in user_extensions: await create_wallet(
try: user_id=account.id,
user_ext = UserExtension(user=account.id, extension=ext_id, active=True) wallet_name=wallet_name or settings.lnbits_default_wallet_name,
await create_user_extension(user_ext, conn=conn) )
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
# Create default pay link for users with username user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
if account.username and "lnurlp" in user_extensions: for ext_id in user_extensions:
try: try:
await _create_default_pay_link(account, wallet) user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
logger.info(f"Created default pay link for user {account.username}") await create_user_extension(user_ext)
except Exception as e: except Exception as e:
logger.error(f"Failed to create default pay link for user {account.username}: {e}") logger.error(f"Error enabeling default extension {ext_id}: {e}")
# Publish Nostr kind 0 metadata event if user has username and Nostr keys user = await get_user_from_account(account)
if account.username and account.pubkey and account.prvkey:
try:
await _publish_nostr_metadata_event(account)
logger.info(f"Published Nostr metadata event for user {account.username}")
except Exception as e:
logger.error(f"Failed to publish Nostr metadata for user {account.username}: {e}")
user = await get_user_from_account(account, conn=conn)
if not user: if not user:
raise ValueError("Cannot find user for account.") raise ValueError("Cannot find user for account.")
@ -210,160 +184,3 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.dict())
async def _create_default_pay_link(account: Account, wallet) -> None:
"""Create a default pay link for new users with username (Bitcoinmat receiving address)"""
try:
# Try dynamic import that works with extensions in different locations
import importlib
import sys
# First try the standard import path
try:
lnurlp_crud = importlib.import_module("lnbits.extensions.lnurlp.crud")
lnurlp_models = importlib.import_module("lnbits.extensions.lnurlp.models")
except ImportError:
# If that fails, try importing from external extensions path
# This handles cases where extensions are in /var/lib/lnbits/extensions
try:
# Add extensions path to sys.path if not already there
extensions_path = (
settings.lnbits_extensions_path or "/var/lib/lnbits/extensions"
)
if extensions_path not in sys.path:
sys.path.insert(0, extensions_path)
lnurlp_crud = importlib.import_module("lnurlp.crud")
lnurlp_models = importlib.import_module("lnurlp.models")
except ImportError as e:
logger.warning(f"lnurlp extension not found in any location: {e}")
return
create_pay_link = lnurlp_crud.create_pay_link
CreatePayLinkData = lnurlp_models.CreatePayLinkData
pay_link_data = CreatePayLinkData(
description="Bitcoinmat Receiving Address",
wallet=wallet.id,
# Note default `currency` is satoshis when set as NULL in db
min=1, # minimum 1 sat
max=500000, # maximum 500,000 sats
comment_chars=140,
username=account.username, # use the username as lightning address
zaps=True,
disposable=True,
)
await create_pay_link(pay_link_data)
logger.info(
f"Successfully created default pay link for user {account.username}"
)
except Exception as e:
logger.error(f"Failed to create default pay link: {e}")
# Don't raise - we don't want user creation to fail if pay link creation fails
async def _publish_nostr_metadata_event(account: Account) -> None:
"""Publish a Nostr kind 0 metadata event for a new user"""
import coincurve
from lnbits.utils.nostr import sign_event
# Create Nostr kind 0 metadata event
metadata = {
"name": account.username,
"display_name": account.username,
"about": f"LNbits user: {account.username}",
}
event = {
"kind": 0,
"created_at": int(time.time()),
"tags": [],
"content": json.dumps(metadata),
}
# Convert hex private key to coincurve PrivateKey
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
# Sign the event
signed_event = sign_event(event, account.pubkey, private_key)
# Publish directly to nostrrelay database (hacky but works around WebSocket issues)
await _insert_event_into_nostrrelay(signed_event, account.username)
async def _insert_event_into_nostrrelay(event: dict, username: str) -> None:
"""Directly insert Nostr event into nostrrelay database (hacky workaround)"""
try:
import importlib
import sys
# Try to import nostrrelay from various possible locations
nostrrelay_crud = None
NostrEvent = None
try:
nostrrelay_crud = importlib.import_module(
"lnbits.extensions.nostrrelay.crud"
)
NostrEvent = importlib.import_module(
"lnbits.extensions.nostrrelay.relay.event"
).NostrEvent
except ImportError:
try:
# Check if nostrrelay is in external extensions path
extensions_path = (
settings.lnbits_extensions_path or "/var/lib/lnbits/extensions"
)
if extensions_path not in sys.path:
sys.path.insert(0, extensions_path)
nostrrelay_crud = importlib.import_module("nostrrelay.crud")
NostrEvent = importlib.import_module(
"nostrrelay.relay.event"
).NostrEvent
except ImportError:
# Try from the lnbits-nostrmarket project path
nostrmarket_path = "/home/padreug/Projects/lnbits-nostrmarket"
if nostrmarket_path not in sys.path:
sys.path.insert(0, nostrmarket_path)
nostrrelay_crud = importlib.import_module("nostrrelay.crud")
NostrEvent = importlib.import_module(
"nostrrelay.relay.event"
).NostrEvent
if not nostrrelay_crud or not NostrEvent:
logger.warning(
"Could not import nostrrelay - skipping direct database insert"
)
return
# Use a default relay_id for the proof of concept
relay_id = "test1"
logger.debug(f"Using relay_id: {relay_id} for nostrrelay event")
# Create NostrEvent object for nostrrelay
nostr_event = NostrEvent(
id=event["id"],
relay_id=relay_id,
publisher=event["pubkey"],
pubkey=event["pubkey"],
created_at=event["created_at"],
kind=event["kind"],
tags=event.get("tags", []),
content=event["content"],
sig=event["sig"],
)
# Insert directly into nostrrelay database
await nostrrelay_crud.create_event(nostr_event)
logger.info(
f"Successfully inserted Nostr metadata event for {username} into nostrrelay database"
)
except Exception as e:
logger.error(f"Failed to insert event into nostrrelay database: {e}")
logger.debug(f"Exception details: {type(e).__name__}: {str(e)}")

View file

@ -1,202 +0,0 @@
from lnbits.core.crud.users import (
get_account,
get_account_by_username_or_email,
update_account,
)
from lnbits.core.crud.wallets import (
create_wallet,
force_delete_wallet,
get_standalone_wallet,
get_wallet,
get_wallets,
update_wallet,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account
from lnbits.core.models.wallets import (
Wallet,
WalletSharePermission,
WalletShareStatus,
WalletType,
)
from lnbits.db import Connection
from lnbits.helpers import sha256s
async def invite_to_wallet(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.username:
raise ValueError("Username or email missing.")
invited_user = await get_account_by_username_or_email(data.username)
if not invited_user:
raise ValueError("Invited user not found.")
request_id = sha256s(invited_user.id + source_wallet.id)
share = source_wallet.extra.find_share_by_id(request_id)
if share:
raise ValueError("User already invited to this wallet.")
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
request_id=request_id,
request_type=WalletShareStatus.INVITE_SENT,
username=data.username,
permissions=data.permissions,
)
await update_wallet(source_wallet)
wallet_owner = await get_account(source_wallet.user)
if not wallet_owner:
raise ValueError("Cannot find wallet owner.")
invited_user.extra.add_wallet_invite_request(
request_id=request_id,
from_user_name=wallet_owner.username or wallet_owner.email,
to_wallet_id=source_wallet.id,
to_wallet_name=source_wallet.name,
)
await update_account(invited_user)
return invite_request
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
invited_user = await get_account(invited_user_id)
if not invited_user:
raise ValueError("Invited user not found.")
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
if not existing_request:
raise ValueError("Invitation not found.")
invited_user.extra.remove_wallet_invite_request(share_request_id)
await update_account(invited_user)
async def update_wallet_share_permissions(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.shared_with_wallet_id:
raise ValueError("Wallet ID missing.")
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
if not share:
raise ValueError("Share not found")
if not share.shared_with_wallet_id:
raise ValueError("Share does not have a mirror wallet ID.")
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
if not mirror_wallet:
raise ValueError("Target wallet not found")
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
share.approve(permissions=data.permissions)
await update_wallet(source_wallet)
return share
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
if not source_wallet.is_lightning_wallet:
raise ValueError("Source wallet is not a lightning wallet.")
share = source_wallet.extra.find_share_by_id(request_id)
if not share:
raise ValueError("Wallet share not found.")
source_wallet.extra.remove_share_by_id(request_id)
invited_user = await get_account_by_username_or_email(share.username)
if not invited_user:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Invited user not found."
)
if invited_user.extra.find_wallet_invite_request(request_id):
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
mirror_wallets = await get_wallets(
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
)
mirror_wallet = next(
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
)
if not mirror_wallet:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Target wallet not found."
)
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared lightning wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
await force_delete_wallet(mirror_wallet.id)
await update_wallet(source_wallet)
return SimpleStatus(success=True, message="Permission removed.")
async def create_lightning_shared_wallet(
user_id: str,
source_wallet_id: str,
conn: Connection | None = None,
) -> Wallet:
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
if not source_wallet:
raise ValueError("Shared wallet does not exist.")
if not source_wallet.is_lightning_wallet:
raise ValueError("Shared wallet is not a lightning wallet.")
if source_wallet.user == user_id:
raise ValueError("Cannot mirror your own wallet.")
invited_user = await get_account(user_id, conn=conn)
if not invited_user:
raise ValueError("Cannot find invited user.")
return await _accept_invitation_to_shared_wallet(
invited_user, source_wallet, conn=conn
)
async def _accept_invitation_to_shared_wallet(
invited_user: Account,
source_wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
request_id = sha256s(invited_user.id + source_wallet.id)
existing_request = source_wallet.extra.find_share_by_id(request_id)
if not existing_request:
raise ValueError("No invitation found for this invited user.")
if existing_request.status == WalletShareStatus.APPROVED:
raise ValueError("This wallet is already shared with you.")
if existing_request.status != WalletShareStatus.INVITE_SENT:
raise ValueError("Unknown request type.")
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
# todo: double check if user already has a mirror wallet for this source wallet
mirror_wallet = await create_wallet(
user_id=invited_user.id,
wallet_name=source_wallet.name,
wallet_type=WalletType.LIGHTNING_SHARED,
shared_wallet_id=source_wallet.id,
conn=conn,
)
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
await update_wallet(source_wallet, conn=conn)
mirror_wallet.mirror_shared_wallet(source_wallet)
return mirror_wallet

View file

@ -1,4 +1,6 @@
import asyncio import asyncio
import traceback
from collections.abc import Callable, Coroutine
from loguru import logger from loguru import logger
@ -25,6 +27,7 @@ from lnbits.core.services.notifications import (
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue() audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
@ -35,7 +38,7 @@ async def run_by_the_minute_tasks() -> None:
while settings.lnbits_running: while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60 status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0: if settings.notification_balance_delta_changed:
try: try:
# runs by default every minute, the delta should not change that often # runs by default every minute, the delta should not change that often
await check_balance_delta_changed() await check_balance_delta_changed()
@ -57,9 +60,7 @@ async def run_by_the_minute_tasks() -> None:
if minute_counter % 60 == 0: if minute_counter % 60 == 0:
try: try:
# initialize the list of all extensions # initialize the list of all extensions
await InstallableExtension.get_installable_extensions( await InstallableExtension.get_installable_extensions()
post_refresh_cache=True
)
except Exception as ex: except Exception as ex:
logger.error(ex) logger.error(ex)
@ -161,3 +162,14 @@ async def collect_exchange_rates_data() -> None:
else: else:
sleep_time = 60 sleep_time = 60
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
def _create_unique_task(name: str, func: Callable):
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
return await func()
try:
create_unique_task(name, _to_coro(func))
except Exception as e:
logger.error(f"Error in {name} task", e)
logger.error(traceback.format_exc())

View file

@ -1,6 +1,7 @@
<template id="lnbits-admin-audit"> <q-tab-panel name="audit">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6> <h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg"> <div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
@ -49,12 +50,8 @@
<span v-text="$t('audit_record_warning')"></span> <span v-text="$t('audit_record_warning')"></span>
<br /> <br />
<ul> <ul>
<li> <li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<span v-text="$t('audit_record_req_warning_1')"></span> <li><span v-text="$t('audit_record_req_warning_2')"></span></li>
</li>
<li>
<span v-text="$t('audit_record_req_warning_2')"></span>
</li>
</ul> </ul>
<br /> <br />
<span v-text="$t('audit_record_use')"></span> <span v-text="$t('audit_record_use')"></span>
@ -136,15 +133,7 @@
multiple multiple
:hint="$t('audit_http_methods_hint')" :hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')" :label="$t('audit_http_methods_label')"
:options="[ :options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS'
]"
></q-select> ></q-select>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
@ -236,4 +225,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -0,0 +1,197 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
<div class="q-pa-sm">
<canvas
style="
width: 100% !important;
height: auto !important;
min-height: 350px;
max-height: 50vh;
"
ref="exchangeRatesChart"
></canvas>
</div>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number"
label="Refresh Interval (seconds)"
hint="How often should the exchange rates be fetched. Set to zero to disable."
>
</q-input>
<q-input
filled
v-model="formData.lnbits_exchange_history_size"
type="number"
label="History Size"
hint="How many data points should be kept in memory."
>
</q-input>
<ul>
<li>
<code>Refresh Interval</code> and <code> History Size </code>are for
historical purposes only.
</li>
<li>These two settings do not affect the live price computation.</li>
<li>
Chart currency:
<strong
><span
v-text="formData.lnbits_default_accounting_currency || 'USD'"
></span
></strong>
</li>
</ul>
</div>
</div>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@click="addExchangeProvider()"
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
>
</q-btn>
</div>
<div class="col-6">
<q-btn
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
flat
:label="$t('reset_defaults')"
color="primary"
class="float-right"
>
</q-btn>
</div>
</div>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
:columns="exchangesTable.columns"
v-model:pagination="exchangesTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
@click="removeExchangeProvider(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.name"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.api_url"
@update:model-value="touchSettings()"
type="text"
>
</q-input
></q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.path"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
v-model="props.row.exclude_to"
@update:model-value="touchSettings()"
multiple
:options="{{ currencies | safe }}"
></q-select>
</q-td>
<q-td>
<q-btn
@click="showTickerConversionDialog(props.row)"
round
icon="add"
size="sm"
color="gray"
class="q-ml-xs"
>
</q-btn>
<q-chip
v-for="ticker, index in props.row.ticker_conversion"
:key="ticker"
removable
dense
filled
@remove="removeExchangeTickerConversion(props.row, ticker)"
color="primary"
text-color="white"
:label="ticker"
class="ellipsis"
>
</q-chip>
</q-td>
</q-tr>
</template>
</q-table>
<ul>
<li>
<code>API URL</code> and <code>JSON Path</code> fields can use the
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
currency
</li>
<li>
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
lowercase code
</li>
</ul>
<q-separator class="q-ma-md"></q-separator>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_rate_cache_seconds"
type="number"
label="Exchange rate cache (seconds)"
hint="For how many seconds should the exchange rate be cached."
>
</q-input>
</div>
<div class="col-md-8 col-sm-12"></div>
</div>
</q-tab-panel>

View file

@ -1,4 +1,4 @@
<template id="lnbits-admin-extensions"> <q-tab-panel name="extensions">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none">
@ -140,4 +140,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -0,0 +1,287 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.async_payment_failed</code></li>
<li><code>checkout.session.async_payment_succeeded</code></li>
<li><code>checkout.session.completed</code></li>
<li><code>checkout.session.expired</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>

View file

@ -1,4 +1,4 @@
<template id="lnbits-admin-funding"> <q-tab-panel name="funding">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span> <span v-text="$t('wallets_management')"></span>
@ -12,67 +12,45 @@
</p> </p>
<ul> <ul>
<li <li
v-text=" v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
$t('funding_source', {
wallet_class: settings.lnbits_backend_wallet_class
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
$t('node_balance', {
balance: (auditData.node_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
$t('lnbits_balance', {
balance: (auditData.lnbits_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('funding_reserve_percent', {
$t('funding_reserve_percent', { percent: auditData.lnbits_balance_sats > 0
percent: ? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
auditData.lnbits_balance_sats > 0 : 100
? ( })"
(auditData.node_balance_sats /
auditData.lnbits_balance_sats) *
100
).toFixed(2)
: 100
})
"
></li> ></li>
</ul> </ul>
<br /> <br />
</div> </div>
<div class="col"> <div class="col">
<div v-if="LNBITS_NODE_UI"> {% if LNBITS_NODE_UI_AVAILABLE %}
<p> <p><span v-text="$t('node_management')"></span></p>
<span v-text="$t('node_management')"></span> <q-toggle
</p> :label="$t('toggle_node_ui')"
<q-toggle v-model="formData.lnbits_node_ui"
:label="$t('toggle_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui" <q-toggle
></q-toggle> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_public_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_public_node_ui"
:label="$t('toggle_public_node_ui')" ></q-toggle>
v-model="formData.lnbits_public_node_ui" <br />
></q-toggle> <q-toggle
<br /> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_transactions_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_node_ui_transactions"
:label="$t('toggle_transactions_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui_transactions" {% else %}
></q-toggle> <p><span v-text="$t('node_management_not_supported')"></span></p>
</div> {% endif %}
<p v-if="!LNBITS_NODE_UI">
<span v-text="$t('node_management_not_supported')"></span>
</p>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@ -88,9 +66,7 @@
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('fee_reserve_percent')"></span></p>
<span v-text="$t('fee_reserve_percent')"></span>
</p>
<q-input <q-input
type="number" type="number"
filled filled
@ -112,15 +88,7 @@
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('payment_wait_time')"></span></p>
<span v-text="$t('payment_wait_time')"></span>
<sup>
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
<q-tooltip max-width="150px">
<span v-text="$t('payment_wait_time_tooltip')"></span>
</q-tooltip>
</sup>
</p>
<q-input <q-input
type="number" type="number"
filled filled
@ -134,32 +102,10 @@
</div> </div>
</div> </div>
<div v-if="isSuperUser"> <div v-if="isSuperUser">
<lnbits-admin-funding-sources <lnbits-funding-sources
:form-data="formData" :form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources" :allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/> />
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
@ -247,4 +193,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>

View file

@ -1,4 +1,4 @@
<template id="lnbits-admin-notifications"> <q-tab-panel name="notifications">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span> <span v-text="$t('notifications_configure')"></span>
@ -76,25 +76,19 @@
icon="add" icon="add"
></q-btn> ></q-btn>
</q-input> </q-input>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="identifier"></span
></q-chip>
</div>
</q-item-section> </q-item-section>
</q-item> </q-item>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
class="ellipsis"
:label="identifier"
><q-tooltip
v-if="identifier"
anchor="top middle"
self="bottom middle"
><span v-text="identifier"></span></q-tooltip
></q-chip>
</div>
</div> </div>
<div class="col-sm-12 col-md-6"> <div class="col-sm-12 col-md-6">
@ -404,14 +398,13 @@
v-text="$t('notification_balance_delta_changed_desc')" v-text="$t('notification_balance_delta_changed_desc')"
></q-item-label> ></q-item-label>
</q-item-section> </q-item-section>
<q-item-section avatar> <q-item-section avatar>
<q-input <q-toggle
class="flow-right" size="md"
type="number" v-model="formData.notification_balance_delta_changed"
min="0" checked-icon="check"
filled color="green"
v-model="formData.notification_balance_delta_threshold_sats" unchecked-icon="clear"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@ -479,9 +472,7 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_incoming_payment_amount_sats"
formData.lnbits_notification_incoming_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@ -504,13 +495,11 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_outgoing_payment_amount_sats"
formData.lnbits_notification_outgoing_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -1,10 +1,8 @@
<template id="lnbits-admin-security"> <q-tab-panel name="security">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<span v-text="$t('server_management')"></span>
</h6>
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-md-6">
<p><span v-text="$t('base_url')"></span></p> <p><span v-text="$t('base_url')"></span></p>
<q-input <q-input
filled filled
@ -18,7 +16,7 @@
<span v-text="$t('authentication')"></span> <span v-text="$t('authentication')"></span>
</h6> </h6>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"> <div class="col-12 col-sm-6">
<q-input <q-input
filled filled
v-model="formData.auth_token_expire_minutes" v-model="formData.auth_token_expire_minutes"
@ -28,17 +26,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-sm-6">
<q-input
filled
v-model="formData.auth_authentication_cache_minutes"
type="number"
:label="$t('auth_authentication_cache_label')"
:hint="$t('auth_authentication_cache_hint')"
>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-select <q-select
filled filled
v-model="formData.auth_allowed_methods" v-model="formData.auth_allowed_methods"
@ -46,10 +34,6 @@
:hint="$t('auth_allowed_methods_hint')" :hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')" :label="$t('auth_allowed_methods_label')"
:options="formData.auth_all_methods" :options="formData.auth_all_methods"
:option-label="
option =>
option.length > 25 ? option.substring(0, 22) + '...' : option
"
></q-select> ></q-select>
</div> </div>
</div> </div>
@ -95,7 +79,7 @@
<strong class="q-my-none q-mb-sm">Google Auth</strong> <strong class="q-my-none q-mb-sm">Google Auth</strong>
<div class="row"> <div class="row">
<div class="col-12 col-md-6 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.google_client_id" v-model="formData.google_client_id"
@ -104,7 +88,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-md-6 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.google_client_secret" v-model="formData.google_client_secret"
@ -122,7 +106,7 @@
<strong class="q-my-none q-mb-sm">GitHub Auth</strong> <strong class="q-my-none q-mb-sm">GitHub Auth</strong>
<div class="row"> <div class="row">
<div class="col-12 col-md-6 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.github_client_id" v-model="formData.github_client_id"
@ -131,7 +115,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-md-6 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.github_client_secret" v-model="formData.github_client_secret"
@ -149,7 +133,7 @@
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong> <strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row q-col-gutter-sm q-col-gutter-y-md"> <div class="row q-col-gutter-sm q-col-gutter-y-md">
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_discovery_url" v-model="formData.keycloak_discovery_url"
@ -157,7 +141,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_id" v-model="formData.keycloak_client_id"
@ -166,7 +150,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_secret" v-model="formData.keycloak_client_secret"
@ -175,7 +159,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_custom_org" v-model="formData.keycloak_client_custom_org"
@ -183,7 +167,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-8"> <div class="col-md-8 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_custom_icon" v-model="formData.keycloak_client_custom_icon"
@ -221,11 +205,7 @@
dense dense
flat flat
color="primary" color="primary"
:label=" :label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
serverlogEnabled
? $t('disable_server_log')
: $t('enable_server_log')
"
></q-btn> ></q-btn>
</div> </div>
<br /> <br />
@ -233,7 +213,7 @@
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('ip_blocker')"></p> <p v-text="$t('ip_blocker')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
v-model="formBlockedIPs" v-model="formBlockedIPs"
@ -263,7 +243,7 @@
</div> </div>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
v-model="formAllowedIPs" v-model="formAllowedIPs"
@ -299,7 +279,7 @@
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
type="number" type="number"
@ -307,10 +287,10 @@
:label="$t('number_of_requests')" :label="$t('number_of_requests')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<q-select <q-select
filled filled
:options="[$t('second'), $t('minute'), $t('hour')]" :options="[$t('second'),$t('minute'),$t('hour')]"
v-model="formData.lnbits_rate_limit_unit" v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')" :label="$t('time_unit')"
></q-select> ></q-select>
@ -356,4 +336,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -1,51 +1,37 @@
<template id="lnbits-admin-server"> <q-tab-panel name="server">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<span v-text="$t('currency_settings')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('allowed_currencies')"></span></p>
<span v-text="$t('allowed_currencies')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_allowed_currencies" v-model="formData.lnbits_allowed_currencies"
multiple multiple
:hint="$t('allowed_currencies_hint')" :hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')" :label="$t('allowed_currencies')"
:options="g.currencies" :options="{{ currencies | safe }}"
></q-select> ></q-select>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('default_account_currency')"></span></p>
<span v-text="$t('default_account_currency')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_default_accounting_currency" v-model="formData.lnbits_default_accounting_currency"
clearable clearable
:hint="$t('default_account_currency_hint')" :hint="$t('default_account_currency_hint')"
:label="$t('currency')" :label="$t('currency')"
:options=" :options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
formData.lnbits_allowed_currencies?.length
? formData.lnbits_allowed_currencies
: g.allowedCurrencies
"
></q-select> ></q-select>
</div> </div>
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<span v-text="$t('payments')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<span v-text="$t('max_outgoing_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@ -58,9 +44,7 @@
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<span v-text="$t('max_incoming_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@ -75,9 +59,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<span v-text="$t('wallet_limiter')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<q-input <q-input
@ -113,9 +95,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<span v-text="$t('service_fee')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p> <p><span v-text="$t('service_fee')"></span></p>
@ -151,9 +131,7 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('disable_fee_internal')"></span></p>
<span v-text="$t('disable_fee_internal')"></span>
</p>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label> <q-item-label v-text="$t('disable_fee')"></q-item-label>
@ -177,4 +155,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -1,8 +1,6 @@
<template id="lnbits-admin-site-customisation"> <q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<span v-text="$t('ui_management')"></span>
</h6>
<br /> <br />
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@ -12,9 +10,7 @@
filled filled
type="text" type="text"
v-model="formData.lnbits_site_title" v-model="formData.lnbits_site_title"
:label=" :label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')
"
></q-input> ></q-input>
<br /> <br />
</div> </div>
@ -32,19 +28,13 @@
<q-toggle <q-toggle
:tip="$t('ui_toggle_elements_tip')" :tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements" v-model="formData.lnbits_show_home_page_elements"
:label=" :label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
formData.lnbits_show_home_page_elements
? $t('ui_elements_enable')
: $t('ui_elements_disable')
"
></q-toggle> ></q-toggle>
</div> </div>
</div> </div>
<div> <div>
<p> <p><span v-text="$t('ui_site_description')"></span></p>
<span v-text="$t('ui_site_description')"></span>
</p>
<q-input <q-input
v-model="formData.lnbits_site_description" v-model="formData.lnbits_site_description"
filled filled
@ -55,9 +45,7 @@
<br /> <br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p> <p><span v-text="$t('ui_default_wallet_name')"></span></p>
<span v-text="$t('ui_default_wallet_name')"></span>
</p>
<q-input <q-input
filled filled
type="text" type="text"
@ -75,16 +63,6 @@
:hint="$t('ui_qr_code_logo_hint')" :hint="$t('ui_qr_code_logo_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_apple_touch_icon')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_apple_touch_icon"
label="https://example.com/image.png"
:hint="$t('ui_apple_touch_icon_hint')"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-md q-mt-md"> <div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@ -173,11 +151,7 @@
</q-input> </q-input>
<q-toggle <q-toggle
v-model="formData.lnbits_ad_space_enabled" v-model="formData.lnbits_ad_space_enabled"
:label=" :label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
formData.lnbits_ad_space_enabled
? $t('ads_enabled')
: $t('ads_disabled')
"
/> />
<br /> <br />
</div> </div>
@ -233,4 +207,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -1,4 +1,4 @@
<template id="lnbits-admin-users"> <q-tab-panel name="users">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span> <span v-text="$t('user_management')"></span>
@ -80,4 +80,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>

View file

@ -0,0 +1,251 @@
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
</q-card>
</div>
</div>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}

View file

@ -0,0 +1,201 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<!-- Optional: Add content here if needed -->
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#audit"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}

View file

@ -0,0 +1,305 @@
<q-expansion-item
group="extras"
icon="vpn_key"
:label="$t('api_keys_api_docs')"
:content-inset-level="0.5"
>
<q-card-section>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,830 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row">
<div class="col-12">
<q-stepper
v-model="step"
ref="stepper"
color="primary"
animated
header-nav
class="q-pt-sm"
@update:model-value="onStepChange"
>
<q-step
:name="1"
title="Describe"
icon="info"
:done="step > 1"
style="min-height: 100px"
>
<div class="row q-col-gutter-md">
<div class="col-12">
<span class="text-h6">
Tell us something about your extension:
</span>
<ul>
<li>This is the first step, you can return and change it.</li>
<li>
The <code>`name`</code> and
<code>`sort description`</code> fields are what the users will
see when browsing the list of extensions.
</li>
<li>
The <code>`id`</code> field is used internally and in the URL of
your extension.
</li>
</ul>
</div>
<!-- todo: add icon -->
<div class="col-12">
<div>
<q-btn
color="primary"
label="Upload Existing config"
@click="$refs.extensionDataInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="extensionDataInput"
accept="application/json"
style="display: none"
@change="onJsonDataInput"
/>
</div>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.name"
label="Extension Name"
hint="The name of your extension"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.id"
label="Extension Id"
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.short_description"
label="Short Description"
hint="A short description that is shown in the extension list."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
v-model="extensionData.description"
label="Description"
hint="A detailed description of your extension."
type="textarea"
rows="3"
maxlength="1000"
>
</q-input>
</div>
</div>
</q-step>
<q-step
:name="2"
title="Settings"
icon="settings"
:done="step > 2"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep2"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('settings')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.settings_data.enabled"
label="Generate Settings Fields"
size="md"
color="green"
/>
<br />
<ul>
<li>Define what settings your extension will have.</li>
<li>
You can choose if each user has its own settings or if the
settings are global (set by the admin).
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.settings_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-md-2 col-sm-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.settings_data.type"
:options="settingsTypes"
></q-select>
</div>
<div class="col-md-10 col-sm-12 q-pt-sm">
<q-badge
v-if="extensionData.settings_data.type === 'user'"
outline
class="text-caption q-ml-md"
>Each user can set its own settings for this extension.</q-badge
>
<q-badge v-else outline class="text-caption q-ml-md"
>Settings are set by the admin and apply to all users of the
extension</q-badge
>
</div>
</div>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.settings_data.fields"
:hide-advanced="true"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="3"
:done="step > 3"
title="Owner Data"
icon="list"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep3"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('owner_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-input
v-model="extensionData.owner_data.name"
filled
label="Owner Table Name"
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
The owner of the extension manages this data. It can add, remove
and update instances of it.
</li>
<li>
Some fileds are present by default, like
<code>created_at</code>, <code>updated_at</code> and
<code>extra</code>.
</li>
</ul>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.owner_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="4"
:done="step > 4"
title="Client Data"
icon="blur_linear"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep4"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('client_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<!-- <q-toggle
v-model="extensionData.client_data.enabled"
label="Generate Client Table"
disable
size="md"
color="green"
/>
<br /> -->
<q-input
v-if="extensionData.client_data.enabled"
v-model="extensionData.client_data.name"
filled
label="Client Table Name"
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
This data is created by users of the extension. Usually when
they submit a form or make a payment.
</li>
<li>
The owner of the extension can view this data, but should not
modify it.
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.client_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.client_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="5"
:done="step > 5"
title="Public Pages"
icon="link"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep5"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('public_page')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.public_page.has_public_page"
label="Generate Public Page"
size="md"
color="green"
/>
<br />
<ul>
<li>
Most extensions have a public page that can be shared (this page
will still be accessible even if you have restricted access to
your LNbits install).
</li>
</ul>
</div>
</div>
<div v-if="extensionData.public_page.has_public_page">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page title</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}

View file

@ -0,0 +1,150 @@
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request(
'PUT',
'/api/v1/auth/first_install',
null,
{
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
}
)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}

View file

@ -0,0 +1,710 @@
{% extends "public.html" %} {% block scripts %}
<style>
:root {
--size: 100px;
--gap: 25px;
}
.btn-fixed-width {
/* width: 45%; */
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(
to right,
hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0)
);
}
.marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.marquee:hover .marquee__group {
animation-play-state: paused;
}
.marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
</style>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div
class="row justify-center items-center"
style="min-height: calc(100vh / 1.618)"
>
<div
class="full-width"
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
>
<div class="row q-mb-md">
<div class="col-12">
<div>
<h5
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}}
</h5>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div> -->
</div>
</div>
</div>
<div class="row">
<q-badge
v-if="isAccessTokenExpired"
class="q-mx-auto q-mb-md"
color="primary"
rounded
>
<div class="text-h6">
<span v-text="$t('session_has_expired')"></span>
</div>
</q-badge>
<q-card bordered class="full-width q-py-md">
<div class="row">
<div
class="col-12"
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
in LNBITS_AUTH_METHODS)%}
<div class="full-height content-center">
<q-card-section>
<div class="text-body1">
<span v-text="$t('claim_desc')"></span>
</div>
</q-card-section>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
</q-card-section>
</div>
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="LNBITS_AUTH_METHODS"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:reset-key="reset_key"
@login="login"
@register="register"
@reset="reset"
>
<div
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered?
<a
href="#"
class="text-secondary cursor-pointer"
@click.prevent="showRegister('username-password')"
>Create an Account</a
>
</p>
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span>
<a
href="#"
class="text-secondary cursor-pointer q-ml-sm"
@click.prevent="showLogin('username-password')"
v-text="$t('login')"
></a>
</p>
</div>
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
:auth-method="authMethod"
@show-login="showLogin"
@show-register="showRegister"
@login-usr="loginUsr"
@create-wallet="createWallet"
>
</user-id-only>
{%endif%} {% endif %}
</div>
<div
class="col-sm-5 col-lg-6 gt-xs"
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
>
<div class="full-height flex flex-center q-pa-lg">
<q-img
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class=""
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class=""
></q-btn>
</div>
</div>
{% if AD_SPACE_ENABLED and AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
</div>
<div class="flex flex-center columm">
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column q-pr-sm">
<a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
class="full-width"
/>
<img
v-else
src="{{ AD[2] }}"
style="max-width: 420px"
class="full-width"
/>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,165 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}

View file

@ -1,3 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}

View file

@ -1,3 +0,0 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}

View file

@ -0,0 +1,379 @@
<q-tab-panel name="channels">
<q-dialog v-model="connectPeerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="connectPeerDialog.data.uri"
label="Node URI"
hint="pubkey@host:port"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('connect')"
color="primary"
@click="connectPeer"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="setFeeDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<label class="text-h6">Set Channel Fee</label>
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
<q-separator></q-separator>
<q-form class="q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_ppm"
label="Fee Rate PPM"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_base_msat"
label="Fee Base msat"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('set')"
color="primary"
@click="setChannelFee(setFeeDialog.channel_id)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="openChannelDialog.data.peer_id"
label="Peer ID"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.funding_amount"
label="Funding Amount"
></q-input>
<q-expansion-item icon="warning" label="Advanced">
<q-card>
<q-card-section>
<div class="column q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.push_amount"
label="Push Amount"
hint="This gifts sats to the other side!"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.fee_rate"
label="Fee Rate"
></q-input>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
:label="$t('open')"
color="primary"
@click="openChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="closeChannelDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<div>
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('close')"
color="primary"
@click="closeChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-card-section class="q-pa-none">
<div class="row q-col-gutter-lg">
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="q-gutter-y-sm">
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
<q-input
filled
dense
clearable
v-model="channels.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-select
dense
size="sm"
style="min-width: 200px"
filled
multiple
clearable
v-model="stateFilters"
:options="this.states"
class="col-auto"
></q-select>
<q-btn
unelevated
color="primary"
size="md"
class="col-auto"
@click="showOpenChannelDialog()"
>
Open channel
</q-btn>
</div>
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
:balance="this.totalBalance"
></lnbits-channel-balance>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="props.row.name"></div>
<div class="text-caption" v-if="props.row.peer_id">
<span>Peer ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.peer_id)"
></q-btn>
</div>
<div class="text-caption col-grow">
<span>Fees</span>
<q-btn
size="xs"
flat
dense
icon="settings"
@click="showSetFeeDialog(props.row.id)"
></q-btn>
<span v-if="props.row.fee_ppm">
<span v-text="props.row.fee_ppm"></span> ppm,
<span v-text="props.row.fee_base_msat"></span> msat
</span>
</div>
<div class="text-caption" v-if="props.row.id">
<span>Channel ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
</div>
<div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.short_id)"
></q-btn>
</div>
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
>
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
flat
dense
size="md"
@click="showCloseChannelDialog(props.row)"
icon="cancel"
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
></lnbits-channel-balance>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="column q-gutter-y-sm">
<div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
>
<div class="col-grow text-h6 q-my-none">Peers</div>
<q-input
filled
dense
clearable
v-model="peers.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-btn
class="col-auto"
color="primary"
@click="connectPeerDialog.show = true"
>
Connect Peer
</q-btn>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow">
<div
class="text-subtitle1 text-bold"
v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
v-text="'#'+props.row.color"
>
</q-badge>
<div
class="text-bold"
v-text="shortenNodeId(props.row.id)"
></div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row)"
></q-btn>
</div>
</div>
<q-btn
unelevated
color="primary"
@click="showOpenChannelDialog(props.row.id)"
>
Open channel
</q-btn>
<q-btn
flat
dense
size="md"
@click="disconnectPeer(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>

View file

@ -0,0 +1,68 @@
<q-tab-panel name="dashboard">
<q-card-section class="q-pa-none">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Fees collected"
:msat="this.info.fees?.total_msat"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Balance"
:btc="this.info.onchain_balance_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Confirmed"
:btc="this.info.onchain_confirmed_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats
:stats="this.channel_stats"
></lnbits-channel-stats>
</div>
</div>
</q-card-section>
</q-tab-panel>

View file

@ -0,0 +1,314 @@
<q-tab-panel name="transactions">
<q-card-section class="q-pa-none">
<q-dialog v-model="transactionDetailsDialog.show" position="top">
<q-card class="my-card">
<q-card-section>
<div class="text-center q-mb-lg">
<div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
>
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
</div>
<div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono">
<span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div
class="row"
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
<span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
</div>
</div>
<div
v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(transactionDetailsDialog.data.bolt11)"
:label="$t('copy_invoice')"
class="q-mt-sm"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<div class="row q-col-gutter-md q-pb-lg"></div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Payments</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@request="getPayments"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_made"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-dialog
v-model="props.row.expand"
:props="props"
position="top"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-td>
</template>
<template v-slot:body-cell-date="props">
<q-td auto-width key="date" :props="props">
<lnbits-date :ts="props.row.time"></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-destination="props">
<q-td auto-width key="destination">
<div class="row items-center justify-between no-wrap">
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
v-text="props.row.destination?.alias"
></q-badge>
<div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(info.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row.destination)"
></q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Invoices</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_received"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-paid_at="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.paid_at"
:ts="props.row.paid_at"
></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-expiry="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.expiry"
:ts="props.row.expiry"
></lnbits-date>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>

View file

@ -0,0 +1,46 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<q-dialog v-model="nodeInfoDialog.show" position="top">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
</q-dialog>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="dashboard"
:label="$t('dashboard')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="channels"
:label="$t('channels')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="transactions"
:label="$t('transactions')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "node/_tab_dashboard.html" %} {% include
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
%}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: [],
activeBalance: {},
ranks: {},
peers: [],
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, data) {
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
},
getInfo: function () {
this.api('GET', '/info', {}).then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
this.api('GET', '/rank', {}).then(response => {
this.ranks = response.data
})
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,432 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col"></div>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentStatus"
:label="$t('payments_status_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentTags"
:label="$t('payments_tag_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalance"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showWalletsSize"
:label="$t('payments_wallets_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<q-btn icon="event" outline flat>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
/>
</q-btn>
<q-separator vertical class="q-ma-sm"></q-separator>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#server"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div v-show="!showDetails">
<div class="row q-col-gutter-md justify-center q-mb-md">
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_status')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tags')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalance"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showWalletsSize"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalanceInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_balance_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentCountInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_count_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="payment_hash"
:rows="payments"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
:filter="paymentsTable.search"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchPaymentsBy()"
@update:model-value="searchPaymentsBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchPaymentsBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-btn
v-else-if="['status'].includes(col.name)"
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'status'">
<q-tooltip
><span v-text="$t('payment_details')"></span
></q-tooltip>
<q-icon
@click="showDetailsToggle(props.row)"
v-if="props.row.status === 'success'"
size="14px"
:name="props.row.outgoing ? 'call_made' : 'call_received'"
:color="props.row.outgoing ? 'pink' : 'green'"
class="cursor-pointer"
></q-icon>
<q-icon
v-else-if="props.row.status === 'pending'"
@click="showDetailsToggle(props.row)"
name="downloading"
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
color="grey"
class="cursor-pointer"
></q-icon>
<q-icon
v-else
@click="showDetailsToggle(props.row)"
name="warning"
color="yellow"
class="cursor-pointer"
></q-icon>
</div>
<div v-else-if="col.name == 'created_at'">
<div>
<q-tooltip anchor="top middle">
<span v-text="formatDate(props.row.created_at)"></span>
</q-tooltip>
<span v-text="props.row.timeFrom"> </span>
</div>
</div>
<div
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip anchor="top middle">Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name], col.max_length)">
</span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
<div v-show="showDetails">
<q-card>
<q-card-section class="flex">
<div>
<q-btn
flat
round
icon="arrow_back"
class="q-mr-md"
@click="showDetailsToggle(null)"
></q-btn>
</div>
<div class="self-center text-h6 text-weight-bolder text-grey-5">
<span v-text="$t('payment_details_back')"></span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-h6">
<q-item>
<q-item-section avatar class="">
<q-icon color="primary" name="receipt" size="44px"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="text-h6">
<span v-text="$t('payment_details')"></span>
</div>
</q-item-label>
<q-item-label caption v-text="$t('payment_details_desc')">
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="(value, key) in paymentDetails" :key="key">
<q-item-section>
<q-item-label v-text="key"></q-item-label>
<q-item-label
caption
v-text="value"
style="word-wrap: break-word"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-show="value"
icon="content_copy"
flat
class="cursor-pointer q-ml-sm"
@click="copyText(value)"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</q-item-section>
<!-- <q-separator></q-separator> -->
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
{% endblock %}

View file

@ -0,0 +1,44 @@
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<div class="row">
<div class="col-12">
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
</q-card>
</q-dialog>

View file

@ -0,0 +1,196 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="'Nostr '+ $t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>

View file

@ -0,0 +1,167 @@
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width v-if="g.user.super_user"></q-th>
<q-th auto-width></q-th>
<q-th
auto-width
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
size="sm"
color="secondary"
@click="showPayments(props.row.id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
<span v-text="props.row.name"></span>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>

View file

@ -0,0 +1,186 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
>
</q-btn>
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#users"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="users"
:columns="usersTable.columns"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@request="fetchUsers"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
>
<q-tooltip>Copy Public Key</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,15 +1,20 @@
import os import os
import time import time
from http import HTTPStatus from http import HTTPStatus
from shutil import make_archive from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import APIRouter, Depends, File import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import ( from lnbits.core.services import (
enqueue_admin_notification, enqueue_admin_notification,
get_balance_delta, get_balance_delta,
@ -18,6 +23,7 @@ from lnbits.core.services import (
from lnbits.core.services.notifications import send_email_notification from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners from lnbits.tasks import invoice_listeners
@ -67,9 +73,9 @@ async def api_test_email():
@admin_router.get("/api/v1/settings") @admin_router.get("/api/v1/settings")
async def api_get_settings( async def api_get_settings(
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> AdminSettings | None: ) -> AdminSettings | None:
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
return admin_settings return admin_settings
@ -77,14 +83,12 @@ async def api_get_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings( async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
data: UpdateSettings, account: Account = Depends(check_admin)
):
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
if not admin_settings: if not admin_settings:
raise ValueError("Updated admin settings not found.") raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict()) update_cached_settings(admin_settings.dict())
@ -96,11 +100,9 @@ async def api_update_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings_partial( async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data}) updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, account) return await api_update_settings(updatable_settings, user)
@admin_router.get( @admin_router.get(
@ -114,9 +116,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK) @admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(account: Account = Depends(check_super_user)) -> None: async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await reset_core_settings() await reset_core_settings()
server_restart.set() server_restart.set()
@ -170,3 +172,93 @@ async def api_download_backup() -> FileResponse:
return FileResponse( return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip" path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
) )
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")

View file

@ -8,17 +8,13 @@ from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet,
ConversionData, ConversionData,
CreateWallet, CreateWallet,
User, User,
Wallet, Wallet,
) )
from lnbits.core.models.users import AccountId from lnbits.decorators import check_user_exists
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_user_exists,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
@ -43,9 +39,7 @@ async def health() -> dict:
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK) @api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check( async def health_check(user: User = Depends(check_user_exists)) -> dict:
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
stat: dict[str, Any] = { stat: dict[str, Any] = {
"server_time": int(time()), "server_time": int(time()),
"up_time": settings.lnbits_server_up_time, "up_time": settings.lnbits_server_up_time,
@ -53,7 +47,7 @@ async def health_check(
} }
stat["version"] = settings.version stat["version"] = settings.version
if not account_id.is_admin_id: if not user.admin:
return stat return stat
funding_source = get_funding_source() funding_source = get_funding_source()
@ -70,6 +64,7 @@ async def health_check(
"/api/v1/wallets", "/api/v1/wallets",
name="Wallets", name="Wallets",
description="Get basic info for all of user's wallets.", description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
) )
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]: async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets return user.wallets
@ -83,7 +78,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
@api_router.get( @api_router.get(
"/api/v1/rate/history", "/api/v1/rate/history",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def api_exchange_rate_history() -> list[dict]: async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history return settings.lnbits_exchange_rate_history
@ -100,16 +95,6 @@ async def api_list_currencies_available() -> list[str]:
return allowed_currencies() return allowed_currencies()
@api_router.get("/api/v1/default-currency")
async def api_get_default_currency() -> dict[str, str | None]:
"""
Get the default accounting currency for this LNbits instance.
Returns the configured default, or None if not set.
"""
default_currency = settings.lnbits_default_accounting_currency
return {"default_currency": default_currency}
@api_router.post("/api/v1/conversion") @api_router.post("/api/v1/conversion")
async def api_fiat_as_sats(data: ConversionData): async def api_fiat_as_sats(data: ConversionData):
output = {} output = {}
@ -128,9 +113,8 @@ async def api_fiat_as_sats(data: ConversionData):
return output return output
@api_router.get("/api/v1/qrcode", response_class=StreamingResponse)
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) @api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(data: str): async def img(data):
qr = pyqrcode.create(data) qr = pyqrcode.create(data)
stream = BytesIO() stream = BytesIO()
qr.svg(stream, scale=3) qr.svg(stream, scale=3)

View file

@ -1,174 +0,0 @@
import base64
from http import HTTPStatus
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from lnbits.core.crud.assets import (
delete_user_asset,
get_asset_info,
get_public_asset,
get_public_asset_info,
get_user_asset,
get_user_asset_info,
get_user_assets,
update_user_asset_info,
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
optional_user_id,
parse_filters,
)
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
upload_file_param = File(...)
@asset_router.get(
"/paginated",
name="Get user assets",
summary="Get paginated list user assets",
)
async def api_get_user_assets(
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(account_id.id, filters=filters)
@asset_router.get(
"/{asset_id}",
name="Get user asset",
summary="Get user asset by ID",
)
async def api_get_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
)
async def api_get_asset_binary(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset = None
if user_id:
asset = await get_user_asset(user_id, asset_id)
if not asset:
asset = await get_public_asset(asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
"/{asset_id}/thumbnail",
name="Get user asset thumbnail",
summary="Get user asset thumbnail data by ID",
)
async def api_get_asset_thumbnail(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset_info = None
if user_id:
asset_info = await get_user_asset_info(user_id, asset_id)
if not asset_info:
asset_info = await get_public_asset_info(asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@asset_router.put(
"/{asset_id}",
name="Update user asset",
summary="Update user asset by ID",
)
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
if account_id.is_admin_id:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
asset_info.name = data.name or asset_info.name
asset_info.is_public = (
asset_info.is_public if data.is_public is None else data.is_public
)
await update_user_asset_info(asset_info)
return asset_info
@asset_router.post(
"",
name="Upload",
summary="Upload user assets",
)
async def api_upload_asset(
account_id: AccountId = Depends(check_account_id_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(account_id.id, file, public_asset)
asset_info = await get_user_asset_info(account_id.id, asset.id)
if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.")
return asset_info
@asset_router.delete(
"/{asset_id}",
name="Delete user asset",
summary="Delete user asset by ID",
)
async def api_delete_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
asset = await get_user_asset(account_id.id, asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")

View file

@ -25,12 +25,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList, UpdateAccessControlList,
) )
from lnbits.core.services import create_user_account from lnbits.core.services import create_user_account
from lnbits.core.services.users import update_user_account from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_user_exists,
)
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
decrypt_internal_message, decrypt_internal_message,
@ -75,49 +70,6 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
return user return user
@auth_router.get("/nostr/me", description="Get current user with Nostr keys")
async def get_auth_user_with_nostr(user: User = Depends(check_user_exists)) -> dict:
"""Get current user information including Nostr private key for chat"""
from lnbits.core.crud.users import get_account
# Get the account to access the private key
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"pubkey": user.pubkey,
"prvkey": account.prvkey, # Include private key for Nostr chat
"created_at": user.created_at,
"updated_at": user.updated_at
}
@auth_router.get("/nostr/pubkeys", description="Get all user Nostr public keys")
async def get_nostr_pubkeys(user: User = Depends(check_user_exists)) -> list[dict[str, str]]:
"""Get all user Nostr public keys for chat"""
from lnbits.core.crud.users import get_accounts
from lnbits.db import Filters
# Get all accounts
filters = Filters()
accounts_page = await get_accounts(filters=filters)
pubkeys = []
for account in accounts_page.data:
if account.pubkey: # pubkey is now the Nostr public key
pubkeys.append({
"user_id": account.id,
"username": account.username,
"pubkey": account.pubkey
})
return pubkeys
@auth_router.post("", description="Login via the username and password") @auth_router.post("", description="Login via the username and password")
async def login(data: LoginUsernamePassword) -> JSONResponse: async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password): if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
@ -166,11 +118,11 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
@auth_router.get("/acl") @auth_router.get("/acl")
async def api_get_user_acls( async def api_get_user_acls(
request: Request, request: Request,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
api_routes = get_api_routes(request.app.router.routes) api_routes = get_api_routes(request.app.router.routes)
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
# Add missing/new endpoints to the ACLs # Add missing/new endpoints to the ACLs
for acl in acls.access_control_list: for acl in acls.access_control_list:
@ -183,7 +135,7 @@ async def api_get_user_acls(
acl.endpoints.append(EndpointAccess(path=path, name=name)) acl.endpoints.append(EndpointAccess(path=path, name=name))
acl.endpoints.sort(key=lambda e: e.name.lower()) acl.endpoints.sort(key=lambda e: e.name.lower())
return UserAcls(id=account.id, access_control_list=acls.access_control_list) return UserAcls(id=user.id, access_control_list=acls.access_control_list)
@auth_router.put("/acl") @auth_router.put("/acl")
@ -191,13 +143,13 @@ async def api_get_user_acls(
async def api_update_user_acl( async def api_update_user_acl(
request: Request, request: Request,
data: UpdateAccessControlList, data: UpdateAccessControlList,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
acl = user_acls.get_acl_by_id(data.id) acl = user_acls.get_acl_by_id(data.id)
if acl: if acl:
user_acls.access_control_list.remove(acl) user_acls.access_control_list.remove(acl)
@ -222,30 +174,33 @@ async def api_update_user_acl(
@auth_router.delete("/acl") @auth_router.delete("/acl")
async def api_delete_user_acl( async def api_delete_user_acl(
data: DeleteAccessControlList, account: Account = Depends(check_account_exists) data: DeleteAccessControlList,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
user_acls.delete_acl_by_id(data.id) user_acls.delete_acl_by_id(data.id)
await update_user_access_control_list(user_acls) await update_user_access_control_list(user_acls)
@auth_router.post("/acl/token") @auth_router.post("/acl/token")
async def api_create_user_api_token( async def api_create_user_api_token(
data: ApiTokenRequest, account: Account = Depends(check_account_exists) data: ApiTokenRequest,
user: User = Depends(check_user_exists),
) -> ApiTokenResponse: ) -> ApiTokenResponse:
if not data.expiration_time_minutes > 0: if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.") raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@ -262,16 +217,18 @@ async def api_create_user_api_token(
@auth_router.delete("/acl/token") @auth_router.delete("/acl/token")
async def api_delete_user_api_token( async def api_delete_user_api_token(
data: DeleteTokenRequest, account: Account = Depends(check_account_exists) data: DeleteTokenRequest,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@ -360,20 +317,24 @@ async def register(data: RegisterUser) -> JSONResponse:
@auth_router.put("/pubkey") @auth_router.put("/pubkey")
async def update_pubkey( async def update_pubkey(
data: UpdateUserPubkey, data: UpdateUserPubkey,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if ( if (
data.pubkey data.pubkey
and data.pubkey != account.pubkey and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey) and await get_account_by_pubkey(data.pubkey)
): ):
raise ValueError("Public key already in use.") raise ValueError("Public key already in use.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey) account.pubkey = normalize_public_key(data.pubkey)
await update_account(account) await update_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)
@ -382,19 +343,23 @@ async def update_pubkey(
@auth_router.put("/password") @auth_router.put("/password")
async def update_password( async def update_password(
data: UpdateUserPassword, data: UpdateUserPassword,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
if ( if (
data.username data.username
and account.username != data.username and user.username != data.username
and await get_account_by_username(data.username) and await get_account_by_username(data.username)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise ValueError("Account not found.")
# old accounts do not have a password # old accounts do not have a password
if account.password_hash: if account.password_hash:
if not data.password_old: if not data.password_old:
@ -453,17 +418,30 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
@auth_router.put("/update") @auth_router.put("/update")
async def update( async def update(
data: UpdateUser, account: Account = Depends(check_account_exists) data: UpdateUser, user: User = Depends(check_user_exists)
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username: if data.username:
account.username = data.username account.username = data.username
if data.extra: if data.extra:
account.extra = data.extra account.extra = data.extra
await update_user_account(account) await update_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)

View file

@ -1,19 +1,10 @@
import json
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from loguru import logger
from lnbits.core.crud.payments import (
get_standalone_payment,
)
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook, handle_stripe_event,
) )
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.settings import settings from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@ -38,223 +29,7 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "paypal":
payload = await request.body()
await verify_paypal_webhook(request.headers, payload)
event = await request.json()
await handle_paypal_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
) )
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_type = event.get("type")
if event_type == "checkout.session.completed":
await _handle_stripe_checkout_session_completed(event)
elif event_type == "payment_intent.succeeded":
await _handle_stripe_intent_session_completed(event)
elif event_type == "invoice.paid":
await _handle_stripe_subscription_invoice_paid(event)
else:
logger.info(
f"Unhandled Stripe event type: '{event_type}'." f" Event ID: '{event_id}'."
)
async def _handle_stripe_intent_session_completed(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
async def _handle_stripe_checkout_session_completed(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
alan_action = event_object.get("metadata", {}).get("alan_action")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if alan_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.")
return
if not payment_hash:
raise ValueError("Stripe event does not contain a payment hash.")
payment = await get_standalone_payment(payment_hash)
if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await payment.check_fiat_status()
async def _handle_stripe_subscription_invoice_paid(event: dict):
invoice = event.get("data", {}).get("object", {})
parent = invoice.get("parent", {})
currency = invoice.get("currency", "").upper()
if not currency:
raise ValueError("Stripe invoice.paid event missing 'currency'.")
amount_paid = invoice.get("amount_paid")
if not amount_paid:
raise ValueError("Stripe invoice.paid event missing 'amount_paid'.")
payment_options = await _get_stripe_subscription_payment_options(parent)
if not payment_options.wallet_id:
raise ValueError("Stripe invoice.paid event missing 'wallet_id' in metadata.")
memo = " | ".join(
[i.get("description", "") for i in invoice.get("lines", {}).get("data", [])]
+ [payment_options.memo or "", invoice.get("customer_email", "")]
)
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": invoice.get("id"),
"payment_request": invoice.get("hosted_invoice_url"),
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount_paid / 100, # convert cents to dollars
memo=memo,
extra=extra,
fiat_provider="stripe",
),
)
await payment.check_fiat_status()
async def _get_stripe_subscription_payment_options(
parent: dict,
) -> FiatSubscriptionPaymentOptions:
if not parent or not parent.get("type") == "subscription_details":
raise ValueError("Stripe invoice.paid event does not contain a subscription.")
metadata = parent.get("subscription_details", {}).get("metadata", {})
if metadata.get("alan_action") != "subscription":
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
if "extra" in metadata:
try:
metadata["extra"] = json.loads(metadata["extra"])
except json.JSONDecodeError as exc:
logger.warning(exc)
metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_type = event.get("event_type", "")
resource = event.get("resource", {})
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
return
if event_type in (
"PAYMENT.SALE.COMPLETED",
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
):
await _handle_paypal_subscription_payment(resource)
return
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency_code") or "").upper()
total = amount_info.get("value")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
custom_id = resource.get("custom_id") or resource.get("custom")
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
try:
metadata = json.loads(custom_id)
except json.JSONDecodeError:
metadata = {}
payment_options = FiatSubscriptionPaymentOptions(**metadata)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
"payment_request": "",
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=float(total),
memo=memo,
extra=extra,
fiat_provider="paypal",
),
)
await payment.check_fiat_status()
def _paypal_extract_payment_hash(resource: dict) -> str | None:
purchase_units = resource.get("purchase_units") or []
for pu in purchase_units:
if pu.get("invoice_id"):
return pu.get("invoice_id")
if pu.get("custom_id"):
return pu.get("custom_id")
return None

View file

@ -7,10 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException
from loguru import logger from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
CreateExtension, CreateExtension,
@ -24,7 +23,6 @@ from lnbits.core.models.extensions import (
UserExtension, UserExtension,
UserExtensionInfo, UserExtensionInfo,
) )
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
@ -35,18 +33,15 @@ from lnbits.core.services.extensions import (
uninstall_extension, uninstall_extension,
) )
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_admin, check_admin,
check_user_exists,
) )
from lnbits.settings import settings
from ..crud import ( from ..crud import (
create_user_extension, create_user_extension,
delete_dbversion, delete_dbversion,
drop_extension_db, drop_extension_db,
get_db_version, get_db_version,
get_db_versions,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_user_extension, get_user_extension,
@ -145,10 +140,9 @@ async def api_extension_details(
async def api_update_pay_to_enable( async def api_update_pay_to_enable(
ext_id: str, ext_id: str,
data: PayToEnableInfo, data: PayToEnableInfo,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
user_wallet_ids = await get_wallets_ids(account.id, deleted=False) if data.wallet not in user.wallet_ids:
if data.wallet not in user_wallet_ids:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user." HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
) )
@ -167,7 +161,7 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
@ -181,12 +175,12 @@ async def api_enable_extension(
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
if account_id.is_admin_id or not ext.requires_payment: if user.admin or not ext.requires_payment:
user_ext.active = True user_ext.active = True
await update_user_extension(user_ext) await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
@ -223,13 +217,13 @@ async def api_enable_extension(
@extension_router.put("/{ext_id}/disable") @extension_router.put("/{ext_id}/disable")
async def api_disable_extension( async def api_disable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist." HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active: if not user_ext or not user_ext.active:
return SimpleStatus( return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled." success=True, message=f"Extension '{ext_id}' already disabled."
@ -380,9 +374,7 @@ async def get_pay_to_install_invoice(
@extension_router.put("/{ext_id}/invoice/enable") @extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice( async def get_pay_to_enable_invoice(
ext_id: str, ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
data: PayToEnableInfo,
account_id: AccountId = Depends(check_account_id_exists),
): ):
if not data.amount or data.amount <= 0: if not data.amount or data.amount <= 0:
raise HTTPException( raise HTTPException(
@ -428,9 +420,9 @@ async def get_pay_to_enable_invoice(
memo=f"Enable '{ext.name}' extension.", memo=f"Enable '{ext.name}' extension.",
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo() user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash user_ext_info.payment_hash_to_enable = payment.payment_hash
@ -441,7 +433,7 @@ async def get_pay_to_enable_invoice(
@extension_router.get( @extension_router.get(
"/release/{org}/{repo}/{tag_name}", "/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
@ -462,18 +454,15 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
@extension_router.get("") @extension_router.get("")
async def api_get_user_extensions( async def api_get_user_extensions(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> list[Extension]: ) -> list[Extension]:
async with db.connect() as conn:
user_extensions_ids = [ user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn) return [
] ext
valid_extensions = [ for ext in await get_valid_extensions(False)
ext if ext.code in user_extensions_ids
for ext in await get_valid_extensions(False, conn=conn) ]
if ext.code in user_extensions_ids
]
return valid_extensions
@extension_router.delete( @extension_router.delete(
@ -503,89 +492,3 @@ async def delete_extension_db(ext_id: str):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'", detail=f"Cannot delete data for extension '{ext_id}'",
) from exc ) from exc
# TODO: create a response model for this
@extension_router.get("/all")
async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
async with db.connect() as conn:
installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn
)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn)
]
db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
return extension_data

View file

@ -1,12 +1,14 @@
import os import os
import shutil import shutil
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
Extension, Extension,
@ -15,7 +17,6 @@ from lnbits.core.models.extensions import (
UserExtension, UserExtension,
) )
from lnbits.core.models.extensions_builder import ExtensionData from lnbits.core.models.extensions_builder import ExtensionData
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
install_extension, install_extension,
@ -26,10 +27,10 @@ from lnbits.core.services.extensions_builder import (
zip_directory, zip_directory,
) )
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists,
check_admin, check_admin,
check_extension_builder, check_user_exists,
) )
from lnbits.settings import settings
from ..crud import ( from ..crud import (
create_user_extension, create_user_extension,
@ -46,12 +47,19 @@ extension_builder_router = APIRouter(
@extension_builder_router.post( @extension_builder_router.post(
"/zip", "/zip",
summary="Build and download extension zip.", summary="Build and download extension zip.",
dependencies=[Depends(check_extension_builder)],
description=""" description="""
This endpoint generates a zip file for the extension based on the provided data. This endpoint generates a zip file for the extension based on the provided data.
""", """,
) )
async def api_build_extension(data: ExtensionData) -> FileResponse: async def api_build_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> FileResponse:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
release, build_dir = await build_extension_from_data(data, stub_ext_id) release, build_dir = await build_extension_from_data(data, stub_ext_id)
@ -84,9 +92,9 @@ async def api_build_extension(data: ExtensionData) -> FileResponse:
) )
async def api_deploy_extension( async def api_deploy_extension(
data: ExtensionData, data: ExtensionData,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
working_dir_name = "deploy_" + sha256(account.id.encode("utf-8")).hexdigest() working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
stub_ext_id = "extension_builder_stub" stub_ext_id = "extension_builder_stub"
release, build_dir = await build_extension_from_data( release, build_dir = await build_extension_from_data(
data, stub_ext_id, working_dir_name data, stub_ext_id, working_dir_name
@ -110,9 +118,9 @@ async def api_deploy_extension(
await activate_extension(Extension.from_installable_ext(ext_info)) await activate_extension(Extension.from_installable_ext(ext_info))
user_ext = await get_user_extension(account.id, data.id) user_ext = await get_user_extension(user.id, data.id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account.id, extension=data.id, active=True) user_ext = UserExtension(user=user.id, extension=data.id, active=True)
await create_user_extension(user_ext) await create_user_extension(user_ext)
elif not user_ext.active: elif not user_ext.active:
user_ext.active = True user_ext.active = True
@ -124,14 +132,18 @@ async def api_deploy_extension(
@extension_builder_router.post( @extension_builder_router.post(
"/preview", "/preview",
summary="Build and preview the extension ui.", summary="Build and preview the extension ui.",
dependencies=[Depends(check_extension_builder)],
) )
async def api_preview_extension( async def api_preview_extension(
data: ExtensionData, data: ExtensionData,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> SimpleStatus: ) -> SimpleStatus:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub" stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(account_id.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name) await build_extension_from_data(data, stub_ext_id, working_dir_name)
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.") return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")

View file

@ -3,11 +3,9 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services.fiat_providers import test_connection from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key from lnbits.decorators import check_admin
from lnbits.fiat import StripeWallet, get_fiat_provider from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat") fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@ -21,82 +19,27 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider) return await test_connection(provider)
@fiat_router.post(
"/{provider}/subscription",
status_code=HTTPStatus.OK,
)
async def create_subscription(
provider: str,
data: CreateFiatSubscription,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
wallet_id = data.payment_options.wallet_id
if wallet_id and wallet_id != key_type.wallet.id:
raise HTTPException(
403,
"Wallet id does not match your API key."
"Leave it empty to use your key's wallet.",
)
data.payment_options.wallet_id = key_type.wallet.id
subscription_response = await fiat_provider.create_subscription(
data.subscription_id, data.quantity, data.payment_options
)
return subscription_response
@fiat_router.delete(
"/{provider}/subscription/{subscription_id}",
status_code=HTTPStatus.OK,
)
async def cancel_subscription(
provider: str,
subscription_id: str,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
resp = await fiat_provider.cancel_subscription(subscription_id, key_type.wallet.id)
return resp
@fiat_router.post( @fiat_router.post(
"/{provider}/connection_token", "/{provider}/connection_token",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
) )
async def connection_token(provider: str): async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider) provider_wallet = await get_fiat_provider(provider)
if not fiat_provider: if provider == "stripe":
raise HTTPException(status_code=404, detail="Fiat provider not found") if not isinstance(provider_wallet, StripeWallet):
if provider != "stripe":
raise HTTPException(
status_code=400,
detail=f"Connection tokens are not supported for provider '{provider}'.",
)
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException( raise HTTPException(
status_code=502, detail="Stripe returned no connection token" status_code=500, detail="Stripe wallet/provider not configured"
) )
return {"secret": secret} try:
except Exception as e: tok = await provider_wallet.create_terminal_connection_token()
raise HTTPException( secret = tok.get("secret")
status_code=500, detail="Failed to create connection token" if not secret:
) from e raise HTTPException(
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e

View file

@ -1,29 +1,35 @@
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Annotated
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import httpx import httpx
from fastapi import Depends, Request from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from lnurl import url_decode from lnurl import url_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account from lnbits.core.services import create_invoice, create_user_account
from lnbits.decorators import ( from lnbits.core.services.extensions import get_valid_extensions
check_admin, from lnbits.decorators import check_admin, check_user_exists
check_admin_ui,
check_extension_builder,
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, template_renderer from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from ..crud import get_user from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
)
generic_router = APIRouter( generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False tags=["Core NON-API Website Routes"], include_in_schema=False
@ -35,23 +41,161 @@ async def favicon():
return RedirectResponse(settings.lnbits_qr_logo) return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
request, "core/index.html", {"lnurl": lightning}
)
@generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request):
if not settings.first_install:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
)
return template_renderer().TemplateResponse(
request,
"core/first_install.html",
)
@generic_router.get("/robots.txt", response_class=HTMLResponse) @generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots(): async def robots():
data = "User-agent: *\nDisallow: /" data = """
User-agent: *
Disallow: /
"""
return HTMLResponse(content=data, media_type="text/plain") return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extension_data": extension_data,
"extension_builder_enabled": user.admin
or settings.lnbits_extensions_builder_activate_non_admins,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
)
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
return template_renderer().TemplateResponse(
request,
"core/extensions_builder.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get( @generic_router.get(
"/extensions/builder/preview/{ext_id}", "/extensions/builder/preview/{ext_id}",
name="extensions builder", name="extensions builder",
dependencies=[Depends(check_extension_builder)], response_class=HTMLResponse,
) )
async def extensions_builder_preview( async def extensions_builder_preview(
request: Request, request: Request,
ext_id: str, ext_id: str,
page_name: str | None = None, page_name: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> HTMLResponse: ):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html" html_file_name = "index.html"
if page_name == "public_page": if page_name == "public_page":
@ -76,8 +220,8 @@ async def extensions_builder_preview(
request, request,
"error.html", "error.html",
{ {
"status_code": 404, "err": f"Extension {ext_id} not found",
"message": f"Extension {ext_id} not found, refresh Preview.", "message": "Please 'Refresh Preview' first.",
}, },
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
) )
@ -87,6 +231,7 @@ async def extensions_builder_preview(
html_file_path.as_posix(), html_file_path.as_posix(),
{ {
"user": user.json(), "user": user.json(),
"ajax": _is_ajax_request(request),
}, },
) )
@ -95,10 +240,94 @@ async def extensions_builder_preview(
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'" "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
) )
return response return response
@generic_router.get(
"/wallet",
response_class=HTMLResponse,
description="show wallet page",
)
async def wallet(
request: Request,
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
user: User = Depends(check_user_exists),
wal: UUID4 | None = Query(None),
):
if wal:
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user.wallets.append(wallet)
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet = await get_wallet(lnbits_last_active_wallet)
else:
wallet = user.wallets[0]
if not wallet or wallet.deleted:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found",
)
context = {
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
}
return template_renderer().TemplateResponse(
request,
"core/wallet.html",
{**context, "ajax": _is_ajax_request(request)},
)
@generic_router.get(
"/account",
response_class=HTMLResponse,
description="show account page",
)
async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/service-worker.js") @generic_router.get("/service-worker.js")
async def service_worker(request: Request): async def service_worker(request: Request):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
@ -125,7 +354,7 @@ async def manifest(request: Request, usr: str):
"src": ( "src": (
settings.lnbits_custom_logo settings.lnbits_custom_logo
if settings.lnbits_custom_logo if settings.lnbits_custom_logo
else "images/logos/lnbits.png" else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
), ),
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
@ -192,39 +421,104 @@ async def manifest(request: Request, usr: str):
} }
admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)] @generic_router.get("/node", response_class=HTMLResponse)
async def node(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
funding_source = get_funding_source()
_, balance = await funding_source.status()
@generic_router.get("/payments")
@generic_router.get("/wallet")
@generic_router.get("/wallet/{wallet_id}")
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
@generic_router.get("/admin", dependencies=admin_ui_checks)
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
"index.html", "node/index.html",
{ {
"user": user.json(), "user": user.json(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
}, },
) )
@generic_router.get("/") @generic_router.get("/node/public", response_class=HTMLResponse)
@generic_router.get("/node/public") async def node_public(request: Request):
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) if not settings.lnbits_public_node_ui:
async def index_public(request: Request) -> HTMLResponse: raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"node/public.html",
{
"balance": balance,
},
)
@generic_router.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"admin/index.html",
{
"user": user.json(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/users", response_class=HTMLResponse)
async def users_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return template_renderer().TemplateResponse(
"users/index.html",
{
"request": request,
"user": user.json(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/audit", response_class=HTMLResponse)
async def audit_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_audit_enabled:
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
return template_renderer().TemplateResponse(
"audit/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
@ -285,3 +579,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
return RedirectResponse( return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}", f"/wallet?usr={account.id}&wal={wallet.id}",
) )
def _is_ajax_request(request: Request):
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"

View file

@ -6,17 +6,16 @@ from fastapi import (
Depends, Depends,
HTTPException, HTTPException,
) )
from lnurl import ( from lnurl import LnurlResponseException
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayResponse,
LnurlResponseException,
LnurlWithdrawResponse,
)
from lnurl import execute_login as lnurlauth from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle from lnurl import handle as lnurl_handle
from lnurl.models import LnurlResponseModel from lnurl.models import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
@ -24,7 +23,7 @@ from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
require_admin_key, require_admin_key,
require_base_invoice_key, require_invoice_key,
) )
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
@ -39,7 +38,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5) res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse): if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason) raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlException as exc: except LnurlResponseException as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc ) from exc
@ -48,7 +47,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
@lnurl_router.get( @lnurl_router.get(
"/api/v1/lnurlscan/{code}", "/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_base_invoice_key)], dependencies=[Depends(require_invoice_key)],
deprecated=True, deprecated=True,
response_model=LnurlPayResponse response_model=LnurlPayResponse
| LnurlWithdrawResponse | LnurlWithdrawResponse
@ -64,7 +63,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
@lnurl_router.post( @lnurl_router.post(
"/api/v1/lnurlscan", "/api/v1/lnurlscan",
dependencies=[Depends(require_base_invoice_key)], dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse response_model=LnurlPayResponse
| LnurlWithdrawResponse | LnurlWithdrawResponse
| LnurlAuthResponse | LnurlAuthResponse

View file

@ -3,6 +3,7 @@ from http import HTTPStatus
import httpx import httpx
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.settings import settings from lnbits.settings import settings
@ -153,7 +154,7 @@ async def api_get_payments(
) -> Page[NodePayment] | None: ) -> Page[NodePayment] | None:
if not settings.lnbits_node_ui_transactions: if not settings.lnbits_node_ui_transactions:
raise HTTPException( raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE, HTTP_503_SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI", detail="You can enable node transactions in the Admin UI",
) )
return await node.get_payments(filters) return await node.get_payments(filters)
@ -166,7 +167,7 @@ async def api_get_invoices(
) -> Page[NodeInvoice] | None: ) -> Page[NodeInvoice] | None:
if not settings.lnbits_node_ui_transactions: if not settings.lnbits_node_ui_transactions:
raise HTTPException( raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE, HTTP_503_SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI", detail="You can enable node transactions in the Admin UI",
) )
return await node.get_invoices(filters) return await node.get_invoices(filters)

View file

@ -15,10 +15,7 @@ from lnbits import bolt11
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payment_count_stats, get_payment_count_stats,
get_wallets_stats, get_wallets_stats,
update_payment,
) )
from lnbits.core.crud.users import get_account
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice, CancelInvoice,
CreateInvoice, CreateInvoice,
@ -35,17 +32,14 @@ from lnbits.core.models import (
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.users import User
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_account_id_exists, check_user_exists,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_base_admin_key, require_invoice_key,
require_base_invoice_key,
) )
from lnbits.helpers import ( from lnbits.helpers import (
filter_dict_keys, filter_dict_keys,
@ -70,6 +64,7 @@ from ..services import (
perform_withdraw, perform_withdraw,
settle_hold_invoice, settle_hold_invoice,
update_pending_payment, update_pending_payment,
update_pending_payments,
) )
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"]) payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
@ -84,9 +79,10 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(key_info.wallet.id)
return await get_payments( return await get_payments(
wallet_id=key_info.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
@ -102,10 +98,11 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"), group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(key_info.wallet.id)
return await get_payments_history(key_info.wallet.id, group, filters) return await get_payments_history(key_info.wallet.id, group, filters)
@ -118,14 +115,14 @@ async def api_payments_history(
async def api_payments_counting_stats( async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"), count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id) return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@ -138,14 +135,14 @@ async def api_payments_counting_stats(
) )
async def api_payments_wallets_stats( async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_wallets_stats(filters, user_id=for_user_id) return await get_wallets_stats(filters, user_id=for_user_id)
@ -157,15 +154,15 @@ async def api_payments_wallets_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_daily_stats( async def api_payments_daily_stats(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_payments_daily_stats(filters, user_id=for_user_id) return await get_payments_daily_stats(filters, user_id=for_user_id)
@ -178,30 +175,18 @@ async def api_payments_daily_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_paginated( async def api_payments_paginated(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
recheck_pending: bool = Query(
False, description="Force check and update of pending payments."
),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
) -> Page[Payment]: ):
async with db.connect() as conn: page = await get_payments_paginated(
page = await get_payments_paginated( wallet_id=key_info.wallet.id,
wallet_id=key_info.wallet.id, filters=filters,
filters=filters, )
conn=conn, for payment in page.data:
) if payment.pending:
if not recheck_pending: await update_pending_payment(payment)
return page
payments = [] return page
for payment in page.data:
if payment.pending:
refreshed_payment = await update_pending_payment(payment, conn=conn)
payments.append(refreshed_payment)
else:
payments.append(payment)
return Page(data=payments, total=page.total)
@payment_router.get( @payment_router.get(
@ -214,19 +199,19 @@ async def api_payments_paginated(
) )
async def api_all_payments_paginated( async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
async with db.connect() as conn: return await get_payments_paginated(
return await get_payments_paginated( filters=filters,
filters=filters, user_id=for_user_id, conn=conn user_id=for_user_id,
) )
@payment_router.post( @payment_router.post(
@ -249,10 +234,10 @@ async def api_all_payments_paginated(
) )
async def api_payments_create( async def api_payments_create(
invoice_data: CreateInvoice, invoice_data: CreateInvoice,
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment: ) -> Payment:
wallet_id = key_info.wallet.id wallet_id = wallet.wallet.id
if invoice_data.out is True and key_info.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@ -262,8 +247,6 @@ async def api_payments_create(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels,
amount_msat=invoice_data.amount_msat,
) )
return payment return payment
@ -277,26 +260,6 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data) return await create_payment_request(wallet_id, invoice_data)
@payment_router.put("/{payment_hash}/labels")
async def api_update_payment_labels(
payment_hash: str,
data: UpdatePaymentLabels,
key_type: BaseWalletTypeInfo = Depends(require_base_admin_key),
) -> SimpleStatus:
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
account = await get_account(key_type.wallet.user)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
# only keep labels that belong to the user
user_label_names = [label.name for label in account.extra.labels]
payment.labels = [label for label in data.labels if label in user_label_names]
await update_payment(payment)
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)
@ -338,7 +301,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": False, "status": "failed"} return {"paid": False, "status": "failed"}
try: try:
payment = await update_pending_payment(payment) status = await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}
@ -347,7 +310,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": payment.success, "paid": payment.success,
"status": f"{payment.status!s}", "status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }

View file

@ -7,11 +7,10 @@ from fastapi import (
) )
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
require_admin_key, require_admin_key,
require_base_invoice_key, require_invoice_key,
) )
from ..crud import ( from ..crud import (
@ -51,12 +50,12 @@ async def api_create_tinyurl(
description="get a tinyurl by id", description="get a tinyurl by id",
) )
async def api_get_tinyurl( async def api_get_tinyurl(
tinyurl_id: str, key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key) tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
): ):
try: try:
tinyurl = await get_tinyurl(tinyurl_id) tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl: if tinyurl:
if tinyurl.wallet == key_info.wallet.id: if tinyurl.wallet == wallet.wallet.id:
return tinyurl return tinyurl
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided." status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."

View file

@ -13,7 +13,6 @@ from lnbits.core.crud import (
delete_account, delete_account,
delete_wallet, delete_wallet,
force_delete_wallet, force_delete_wallet,
get_account,
get_accounts, get_accounts,
get_user, get_user,
get_wallet, get_wallet,
@ -41,7 +40,7 @@ from lnbits.core.services import (
update_wallet_balance, update_wallet_balance,
) )
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, check_user_exists, parse_filters from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import ( from lnbits.helpers import (
encrypt_internal_message, encrypt_internal_message,
generate_filter_params_openapi, generate_filter_params_openapi,
@ -103,6 +102,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
id=uuid4().hex, id=uuid4().hex,
username=data.username, username=data.username,
email=data.email, email=data.email,
pubkey=data.pubkey,
external_id=data.external_id, external_id=data.external_id,
extra=data.extra, extra=data.extra,
) )
@ -115,12 +115,12 @@ async def api_create_user(data: CreateUser) -> CreateUser:
@users_router.put("/user/{user_id}", name="Update user") @users_router.put("/user/{user_id}", name="Update user")
async def api_update_user( async def api_update_user(
user_id: str, data: CreateUser, account: Account = Depends(check_admin) user_id: str, data: CreateUser, user: User = Depends(check_admin)
) -> CreateUser: ) -> CreateUser:
if user_id != data.id: if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.") raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and account.id != settings.super_user: if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.", detail="Action only allowed for super user.",
@ -154,7 +154,7 @@ async def api_update_user(
name="Delete user by Id", name="Delete user by Id",
) )
async def api_users_delete_user( async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin) user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus: ) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False) wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0: if len(wallets) > 0:
@ -169,7 +169,7 @@ async def api_users_delete_user(
detail="Cannot delete super user.", detail="Cannot delete super user.",
) )
if user_id in settings.lnbits_admin_users and not account.is_super_user: if user_id in settings.lnbits_admin_users and not user.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.", detail="Only super_user can delete admin user.",
@ -295,7 +295,7 @@ async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
"The second time it is called will delete the entry from the DB", "The second time it is called will delete the entry from the DB",
) )
async def api_users_delete_user_wallet( async def api_users_delete_user_wallet(
user_id: str, wallet: str, account: Account = Depends(check_admin) user_id: str, wallet: str, user: User = Depends(check_admin)
) -> SimpleStatus: ) -> SimpleStatus:
wal = await get_wallet(wallet) wal = await get_wallet(wallet)
if not wal: if not wal:
@ -304,7 +304,7 @@ async def api_users_delete_user_wallet(
detail="Wallet does not exist.", detail="Wallet does not exist.",
) )
if user_id == settings.super_user and account.id != settings.super_user: if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.", detail="Action only allowed for super user.",
@ -338,54 +338,3 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
) )
return SimpleStatus(success=True, message="Balance updated.") return SimpleStatus(success=True, message="Balance updated.")
@users_router.get(
"/nostr/pubkeys",
name="Get all user Nostr public keys",
summary="Get a list of all user Nostr public keys",
dependencies=[], # Override global admin requirement
)
async def api_get_nostr_pubkeys() -> list[dict[str, str]]:
"""Get all user Nostr public keys"""
from lnbits.core.crud.users import get_accounts
from lnbits.db import Filters
# Get all accounts
filters = Filters()
accounts_page = await get_accounts(filters=filters)
pubkeys = []
for account in accounts_page.data:
if account.pubkey: # pubkey is now the Nostr public key
pubkeys.append({
"user_id": account.id,
"username": account.username,
"pubkey": account.pubkey # Use consistent naming
})
return pubkeys
@users_router.get(
"/user/me",
name="Get current user",
summary="Get current user information including private key",
dependencies=[], # Override global admin requirement
)
async def api_get_current_user(user: User = Depends(check_user_exists)) -> dict:
"""Get current user information including private key for Nostr chat"""
# Get the account to access the private key
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"pubkey": user.pubkey,
"prvkey": account.prvkey, # Include private key for Nostr chat
"created_at": user.created_at,
"updated_at": user.updated_at
}

View file

@ -8,38 +8,21 @@ from fastapi import (
HTTPException, HTTPException,
) )
from lnbits.core.crud.wallets import ( from lnbits.core.crud.wallets import get_wallets_paginated
create_wallet, from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
get_wallets_paginated,
)
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.wallets import WalletsFilters
from lnbits.core.models.users import Account, AccountId
from lnbits.core.models.wallets import (
WalletsFilters,
WalletSharePermission,
WalletType,
)
from lnbits.core.services.wallets import (
create_lightning_shared_wallet,
delete_wallet_share,
invite_to_wallet,
reject_wallet_invitation,
update_wallet_share_permissions,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists, check_user_exists,
check_account_id_exists,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi from lnbits.helpers import generate_filter_params_openapi
from lnbits.utils.cache import cache
from ..crud import ( from ..crud import (
create_wallet,
delete_wallet, delete_wallet,
get_wallet, get_wallet,
update_wallet, update_wallet,
@ -68,46 +51,17 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
openapi_extra=generate_filter_params_openapi(WalletsFilters), openapi_extra=generate_filter_params_openapi(WalletsFilters),
) )
async def api_wallets_paginated( async def api_wallets_paginated(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)), filters: Filters = Depends(parse_filters(WalletsFilters)),
): ):
page = await get_wallets_paginated( page = await get_wallets_paginated(
user_id=account_id.id, user_id=user.id,
filters=filters, filters=filters,
) )
return page return page
@wallet_router.put("/share/invite")
async def api_invite_wallet_share(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await invite_to_wallet(key_info.wallet, data)
@wallet_router.delete("/share/invite/{share_request_id}")
async def api_reject_wallet_invitation(
share_request_id: str, invited_user: Account = Depends(check_account_exists)
) -> SimpleStatus:
await reject_wallet_invitation(invited_user.id, share_request_id)
return SimpleStatus(success=True, message="Invitation rejected.")
@wallet_router.put("/share")
async def api_accept_wallet_share_request(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await update_wallet_share_permissions(key_info.wallet, data)
@wallet_router.delete("/share/{share_request_id}")
async def api_delete_wallet_share_permissions(
share_request_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> SimpleStatus:
return await delete_wallet_share(key_info.wallet, share_request_id)
@wallet_router.put("/{new_name}") @wallet_router.put("/{new_name}")
async def api_update_wallet_name( async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key) new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@ -116,7 +70,6 @@ async def api_update_wallet_name(
if not wallet: if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name wallet.name = new_name
await update_wallet(wallet) await update_wallet(wallet)
return { return {
"id": wallet.id, "id": wallet.id,
@ -127,17 +80,12 @@ async def api_update_wallet_name(
@wallet_router.put("/reset/{wallet_id}") @wallet_router.put("/reset/{wallet_id}")
async def api_reset_wallet_keys( async def api_reset_wallet_keys(
wallet_id: str, wallet_id: str, user: User = Depends(check_user_exists)
account_id: AccountId = Depends(check_account_id_exists),
) -> Wallet: ) -> Wallet:
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != account_id.id: if not wallet or wallet.user != user.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
wallet.adminkey = uuid4().hex wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex wallet.inkey = uuid4().hex
await update_wallet(wallet) await update_wallet(wallet)
@ -176,17 +124,16 @@ async def api_update_wallet(
wallet.extra.color = color or wallet.extra.color wallet.extra.color = color or wallet.extra.color
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet) await update_wallet(wallet)
return wallet return wallet
@wallet_router.delete("/{wallet_id}") @wallet_router.delete("/{wallet_id}")
async def api_delete_wallet( async def api_delete_wallet(
wallet_id: str, account_id: AccountId = Depends(check_account_id_exists) wallet_id: str, user: User = Depends(check_user_exists)
) -> None: ) -> None:
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != account_id.id: if not wallet or wallet.user != user.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
await delete_wallet( await delete_wallet(
@ -197,25 +144,7 @@ async def api_delete_wallet(
@wallet_router.post("") @wallet_router.post("")
async def api_create_wallet( async def api_create_wallet(
data: CreateWallet, account_id: AccountId = Depends(check_account_id_exists) data: CreateWallet,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet: ) -> Wallet:
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
if data.wallet_type not in list(WalletType):
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Wallet type {data.wallet_type} does not exist.",
)
if data.wallet_type == WalletType.LIGHTNING_SHARED:
if not data.shared_wallet_id:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Shared wallet ID is required for shared wallets.",
)
return await create_lightning_shared_wallet(
user_id=account_id.id,
source_wallet_id=data.shared_wallet_id,
)
# default WalletType.LIGHTNING:
return await create_wallet(user_id=account_id.id, wallet_name=data.name)

View file

@ -15,9 +15,9 @@ from lnbits.core.models import (
CreateWebPushSubscription, CreateWebPushSubscription,
WebPushSubscription, WebPushSubscription,
) )
from lnbits.core.models.users import AccountId from lnbits.core.models.users import User
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_user_exists,
) )
from ..crud import ( from ..crud import (
@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
async def api_create_webpush_subscription( async def api_create_webpush_subscription(
request: Request, request: Request,
data: CreateWebPushSubscription, data: CreateWebPushSubscription,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> WebPushSubscription: ) -> WebPushSubscription:
try: try:
subscription = json.loads(data.subscription) subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"] endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, account_id.id) subscription = await get_webpush_subscription(endpoint, user.id)
if subscription: if subscription:
return subscription return subscription
else: else:
return await create_webpush_subscription( return await create_webpush_subscription(
endpoint, endpoint,
account_id.id, user.id,
data.subscription, data.subscription,
host, host,
) )
@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
@webpush_router.delete("", status_code=HTTPStatus.OK) @webpush_router.delete("", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription( async def api_delete_webpush_subscription(
request: Request, request: Request,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
try: try:
endpoint = unquote( endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8") base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
) )
count = await delete_webpush_subscription(endpoint, account_id.id) count = await delete_webpush_subscription(endpoint, user.id)
return {"count": count} return {"count": count}
except Exception as exc: except Exception as exc:
logger.debug(exc) logger.debug(exc)

View file

@ -130,12 +130,6 @@ class Compat:
return "BIGINT" return "BIGINT"
return "INT" return "INT"
@property
def blob(self) -> str:
if self.type in {POSTGRES}:
return "BYTEA"
return "BLOB"
def timestamp_placeholder(self, key: str) -> str: def timestamp_placeholder(self, key: str) -> str:
return compat_timestamp_placeholder(key) return compat_timestamp_placeholder(key)
@ -222,34 +216,18 @@ class Connection(Compat):
filters: Filters | None = None, filters: Filters | None = None,
model: type[TModel] | None = None, model: type[TModel] | None = None,
group_by: list[str] | None = None, group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]: ) -> Page[TModel]:
"""
Parameters:
query: The main SQL query string to execute for data retrieval.
where: list of additional WHERE clause conditions to filter results.
values: dictionary of parameter values to be used in the SQL query.
filters: object for advanced filtering, sorting, and pagination logic.
model: pydantic model type to map query results into model instances.
group_by: list of column names to group results by in the SQL query.
table_name: if provided some optimisations can be applied.
"""
if not filters: if not filters:
filters = Filters() filters = Filters()
if table_name:
if not _valid_sql_name(table_name):
raise ValueError(f"Invalid table name: '{table_name}'.")
filters.set_table_name(table_name)
clause = filters.where(where) clause = filters.where(where)
parsed_values = filters.values(values) parsed_values = filters.values(values)
group_by_string = "" group_by_string = ""
if group_by: if group_by:
for field in group_by: for field in group_by:
if not _valid_sql_name(field): if not re.fullmatch(
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
):
raise ValueError("Value for GROUP BY is invalid") raise ValueError("Value for GROUP BY is invalid")
group_by_string = f"GROUP BY {', '.join(group_by)}" group_by_string = f"GROUP BY {', '.join(group_by)}"
@ -267,17 +245,16 @@ class Connection(Compat):
if rows: if rows:
# no need for extra query if no pagination is specified # no need for extra query if no pagination is specified
if filters.offset or filters.limit: if filters.offset or filters.limit:
if table_name: result = await self.execute(
count_query = f"SELECT COUNT(*) as count FROM {table_name} {clause}" # noqa: S608 f"""
else: SELECT COUNT(*) as count FROM (
count_query = f"""SELECT COUNT(*) as count
FROM (
{query} {query}
{clause} {clause}
{group_by_string} {group_by_string}
) as count""" # noqa: S608 ) as count
""", # noqa: S608
result = await self.execute(count_query, parsed_values) parsed_values,
)
row = result.mappings().first() row = result.mappings().first()
result.close() result.close()
count = int(row.get("count", 0)) count = int(row.get("count", 0))
@ -410,12 +387,9 @@ class Database(Compat):
filters: Filters | None = None, filters: Filters | None = None,
model: type[TModel] | None = None, model: type[TModel] | None = None,
group_by: list[str] | None = None, group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]: ) -> Page[TModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page( return await conn.fetch_page(query, where, values, filters, model, group_by)
query, where, values, filters, model, group_by, table_name
)
async def execute(self, query: str, values: dict | None = None): async def execute(self, query: str, values: dict | None = None):
async with self.connect() as conn: async with self.connect() as conn:
@ -450,9 +424,6 @@ class Operator(Enum):
LE = "le" LE = "le"
INCLUDE = "in" INCLUDE = "in"
EXCLUDE = "ex" EXCLUDE = "ex"
LIKE = "like"
EVERY = "every"
ANY = "any"
@property @property
def as_sql(self): def as_sql(self):
@ -472,8 +443,6 @@ class Operator(Enum):
return ">=" return ">="
elif self == Operator.LE: elif self == Operator.LE:
return "<=" return "<="
elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
return "LIKE"
else: else:
raise ValueError("Unknown SQL Operator") raise ValueError("Unknown SQL Operator")
@ -494,7 +463,6 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
table_name: str | None = None
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
model: type[TFilterModel] | None model: type[TFilterModel] | None
@ -504,8 +472,6 @@ class Filter(BaseModel, Generic[TFilterModel]):
def parse_query( def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0 cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
): ):
if i > 1000 or len(raw_values) > 1000:
raise ValueError("Too many filter values")
# Key format: # Key format:
# key[operator] # key[operator]
# e.g. name[eq] # e.g. name[eq]
@ -522,39 +488,27 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__: if field in model.__fields__:
compare_field = model.__fields__[field] compare_field = model.__fields__[field]
values: dict = {} values: dict = {}
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}: for raw_value in raw_values:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors: if errors:
raise ValidationError(errors=[errors], model=model) raise ValidationError(errors=[errors], model=model)
values[f"{field}__{index}"] = validated values[f"{field}__{i}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
return cls(field=field, op=op, values=values, model=model) return cls(field=field, op=op, values=values, model=model)
@property @property
def statement(self) -> str: def statement(self):
prefix = f"{self.table_name}." if self.table_name else ""
stmt = [] stmt = []
for key in self.values.keys() if self.values else []: for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime: clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
placeholder = compat_timestamp_placeholder(key) placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else: else:
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}") placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}: return " OR ".join(stmt)
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
elif self.op == Operator.EVERY:
statement = " AND ".join(stmt)
else:
statement = " OR ".join(stmt)
return f"({statement})"
class Filters(BaseModel, Generic[TFilterModel]): class Filters(BaseModel, Generic[TFilterModel]):
@ -570,14 +524,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
search: str | None = None search: str | None = None
offset: int | None = None offset: int | None = None
limit: int | None = 10 limit: int | None = None
sortby: str | None = None sortby: str | None = None
direction: Literal["asc", "desc"] | None = None direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None model: type[TFilterModel] | None = None
table_name: str | None = None
@root_validator(pre=True) @root_validator(pre=True)
def validate_sortby(cls, values): def validate_sortby(cls, values):
sortby = values.get("sortby") sortby = values.get("sortby")
@ -592,8 +545,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str: def pagination(self) -> str:
stmt = "" stmt = ""
self.limit = self.limit or 10 if self.limit:
stmt += f"LIMIT {min(1000, self.limit)} " stmt += f"LIMIT {self.limit} "
if self.offset: if self.offset:
stmt += f"OFFSET {self.offset}" stmt += f"OFFSET {self.offset}"
return stmt return stmt
@ -619,8 +572,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
def order_by(self) -> str: def order_by(self) -> str:
if self.sortby: if self.sortby:
prefix = f"{self.table_name}." if self.table_name else "" return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return f"ORDER BY {prefix}{self.sortby} {self.direction or 'asc'}"
return "" return ""
def values(self, values: dict | None = None) -> dict: def values(self, values: dict | None = None) -> dict:
@ -630,28 +582,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters: for page_filter in self.filters:
if page_filter.values: if page_filter.values:
for key, value in page_filter.values.items(): for key, value in page_filter.values.items():
if page_filter.op == Operator.LIKE: values[key] = value
values[key] = f"%{value}%"
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
values[key] = f"""%"{value}"%"""
else:
values[key] = value
if self.search and self.model: if self.search and self.model:
values["search"] = f"%{self.search.lower()}%" values["search"] = f"%{self.search.lower()}%"
return values return values
def set_table_name(self, table_name: str) -> None:
self.table_name = table_name
for page_filter in self.filters:
page_filter.table_name = table_name
class DbJsonEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Enum):
return o.value
return super().default(o)
def insert_query(table_name: str, model: BaseModel) -> str: def insert_query(table_name: str, model: BaseModel) -> str:
""" """
@ -707,7 +642,7 @@ def model_to_dict(model: BaseModel) -> dict:
or type_ is dict or type_ is dict
or get_origin(outertype_) is list or get_origin(outertype_) is list
): ):
_dict[key] = json.dumps(value, cls=DbJsonEncoder) _dict[key] = json.dumps(value)
continue continue
_dict[key] = value _dict[key] = value
@ -781,11 +716,3 @@ def _safe_load_json(value: str) -> dict:
# DB is corrupted if it gets here # DB is corrupted if it gets here
logger.error(f"Failed to decode JSON: '{value}'") logger.error(f"Failed to decode JSON: '{value}'")
return {} return {}
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", name)
is not None
)

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