Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4aa0b598c | ||
|
|
aab76e691d | ||
|
|
3ed30be93e | ||
|
|
37f99e7f96 | ||
|
|
a16078a6ba | ||
|
|
c4c03d96a3 | ||
|
|
ab3248da15 | ||
|
|
b4b37cd733 | ||
|
|
26eb7a449c | ||
|
|
695e9b6471 | ||
|
|
5c9511ccfe | ||
|
|
c7b7832a88 | ||
|
|
ff24847980 | ||
|
|
659ec31edd | ||
|
|
edb7d66efc | ||
|
|
efcc5e2148 | ||
|
|
55889abd88 | ||
|
|
063aa4fd06 | ||
|
|
7c0a955b30 | ||
|
|
b1a09af82c | ||
|
|
6d2f39d390 | ||
|
|
18496f0dd9 | ||
|
|
acf6c732ad | ||
|
|
b39bd257e0 | ||
|
|
9aeecd4dc0 | ||
|
|
e750165cc3 | ||
|
|
77906bc817 | ||
|
|
63e728710d | ||
|
|
27fd510142 | ||
|
|
e6de66e1b1 | ||
|
|
4071925f65 | ||
|
|
3c4d186dba | ||
|
|
56aebb9d8f | ||
|
|
beee24bd92 | ||
|
|
375b95c004 | ||
|
|
5345ccaf4e | ||
|
|
efc5233399 | ||
|
|
e4d09c6d12 | ||
|
|
7d0545dae1 | ||
|
|
00fccf513e | ||
|
|
34b8490a2d | ||
|
|
cbbba5c4c7 | ||
|
|
4a0ef7fa1a | ||
|
|
cd8804daca | ||
|
|
39e4fa724a | ||
|
|
3b350858c7 | ||
|
|
7bea591879 | ||
|
|
f74fcea35b | ||
|
|
ceb8203353 | ||
|
|
7b29223e95 | ||
|
|
bd19d78834 | ||
|
|
8458b4d84b | ||
|
|
f92ae8bae1 | ||
|
|
3529f9152f | ||
|
|
6a9089fd98 | ||
|
|
5818c3c2ba | ||
|
|
a38de94e49 | ||
|
|
b4801ce0ab | ||
|
|
51bf344d65 | ||
|
|
32cbf16d91 | ||
|
|
c4d0540e76 | ||
|
|
f8b3644029 | ||
|
|
c2fb45d640 | ||
|
|
e339bb6181 | ||
|
|
2dee26b728 | ||
|
|
d774c7a742 | ||
|
|
6c2b312c92 | ||
|
|
56c8783d9a | ||
|
|
ffecd03c4b | ||
|
|
94d5f37723 | ||
|
|
d89bd7409d | ||
|
|
b48489ef32 | ||
|
|
e686ecda51 | ||
|
|
23b4c2cc16 | ||
|
|
1bb29cf0b1 |
+13
-5
@@ -40,7 +40,7 @@ PORT=5000
|
|||||||
######################################
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
# which fundingsources are allowed in the admin ui
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
@@ -101,10 +101,16 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
|||||||
|
|
||||||
# BoltzWallet
|
# BoltzWallet
|
||||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
# HEXSTRING instead of path also possible
|
||||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||||
|
# HEXSTRING instead of path also possible
|
||||||
|
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||||
BOLTZ_CLIENT_WALLET="lnbits"
|
BOLTZ_CLIENT_WALLET="lnbits"
|
||||||
|
|
||||||
|
# StrikeWallet
|
||||||
|
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||||
|
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||||
|
|
||||||
# ZBDWallet
|
# ZBDWallet
|
||||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
@@ -163,14 +169,16 @@ GITHUB_CLIENT_SECRET=""
|
|||||||
KEYCLOAK_CLIENT_ID=""
|
KEYCLOAK_CLIENT_ID=""
|
||||||
KEYCLOAK_CLIENT_SECRET=""
|
KEYCLOAK_CLIENT_SECRET=""
|
||||||
KEYCLOAK_DISCOVERY_URL=""
|
KEYCLOAK_DISCOVERY_URL=""
|
||||||
|
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||||
|
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||||
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# uvicorn variable, uncomment to allow https behind a proxy
|
# uvicorn variable, allow https behind a proxy
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
# FORWARDED_ALLOW_IPS="*"
|
FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ inputs:
|
|||||||
description: "Python Version"
|
description: "Python Version"
|
||||||
required: true
|
required: true
|
||||||
default: "3.10"
|
default: "3.10"
|
||||||
poetry-version:
|
|
||||||
description: "Poetry Version"
|
|
||||||
default: "1.8.5"
|
|
||||||
node-version:
|
node-version:
|
||||||
description: "Node Version"
|
description: "Node Version"
|
||||||
default: "20.x"
|
default: "20.x"
|
||||||
@@ -27,10 +24,8 @@ runs:
|
|||||||
# cache poetry install via pip
|
# cache poetry install via pip
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
- name: Set up Poetry
|
||||||
uses: abatilo/actions-poetry@v2
|
uses: abatilo/actions-poetry@v2
|
||||||
with:
|
|
||||||
poetry-version: ${{ inputs.poetry-version }}
|
|
||||||
|
|
||||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
|
|||||||
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
||||||
export LNBITS_DATA_FOLDER
|
export LNBITS_DATA_FOLDER
|
||||||
export LNBITS_EXTENSIONS_PATH
|
export LNBITS_EXTENSIONS_PATH
|
||||||
export LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
# Define the LNbits URL
|
# Define the LNbits URL
|
||||||
URL="http://0.0.0.0:5000"
|
URL="http://0.0.0.0:5000"
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10"]
|
python-version: ["3.10"]
|
||||||
poetry-version: ["1.5.1"]
|
|
||||||
uses: ./.github/workflows/jmeter.yml
|
uses: ./.github/workflows/jmeter.yml
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
poetry-version: ${{ matrix.poetry-version }}
|
|
||||||
|
|||||||
@@ -51,3 +51,14 @@ 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
|
||||||
|
|
||||||
|
- name: Build and push boltz
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.boltz
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
default: "3.10"
|
default: "3.10"
|
||||||
type: string
|
type: string
|
||||||
poetry-version:
|
|
||||||
description: "Poetry Version"
|
|
||||||
required: true
|
|
||||||
default: "1.5.1"
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
jmeter:
|
jmeter:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nix:
|
nix:
|
||||||
|
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ __pycache__
|
|||||||
.mypy_cache
|
.mypy_cache
|
||||||
.vscode
|
.vscode
|
||||||
*-lock.json
|
*-lock.json
|
||||||
|
.python-version
|
||||||
|
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|||||||
+8
-5
@@ -4,13 +4,14 @@ RUN apt-get clean
|
|||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Only copy the files required to install the dependencies
|
# Only copy the files required to install the dependencies
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
RUN touch README.md
|
||||||
|
|
||||||
RUN mkdir data
|
RUN mkdir data
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ ENV POETRY_NO_INTERACTION=1 \
|
|||||||
POETRY_VIRTUALENVS_CREATE=1 \
|
POETRY_VIRTUALENVS_CREATE=1 \
|
||||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||||
|
|
||||||
RUN poetry install --only main
|
ARG POETRY_INSTALL_ARGS="--only main"
|
||||||
|
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
|
||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ RUN apt-get update && apt-get -y upgrade && \
|
|||||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
ENV POETRY_NO_INTERACTION=1 \
|
ENV POETRY_NO_INTERACTION=1 \
|
||||||
@@ -46,11 +48,12 @@ WORKDIR /app
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /app/.venv .venv
|
COPY --from=builder /app/.venv .venv
|
||||||
|
|
||||||
RUN poetry install --only main
|
ARG POETRY_INSTALL_ARGS="--only main"
|
||||||
|
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||||
|
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
ENV LNBITS_HOST="0.0.0.0"
|
ENV LNBITS_HOST="0.0.0.0"
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
|
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM boltz/boltz-client:latest AS boltz
|
||||||
|
|
||||||
|
FROM lnbits/lnbits:latest
|
||||||
|
|
||||||
|
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
|
||||||
|
RUN ls -l /usr/local/bin/boltzd
|
||||||
|
RUN apt-get update && apt-get -y upgrade && \
|
||||||
|
apt-get install -y netcat-openbsd
|
||||||
|
|
||||||
|
# Reinstall dependencies just in case (needed for CMD usage)
|
||||||
|
ARG POETRY_INSTALL_ARGS="--only main"
|
||||||
|
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||||
|
|
||||||
|
# LNbits + boltzd configuration
|
||||||
|
ENV LNBITS_PORT="5000"
|
||||||
|
ENV LNBITS_HOST="0.0.0.0"
|
||||||
|
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
||||||
|
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||||
|
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
||||||
|
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
|
||||||
|
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
|
||||||
|
ENV BOLTZ_CLIENT_WALLET="lnbits"
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Entrypoint to start boltzd and LNbits
|
||||||
|
COPY dockerboltz.sh /dockerboltz.sh
|
||||||
|
RUN chmod +x /dockerboltz.sh
|
||||||
|
|
||||||
|
CMD ["/dockerboltz.sh"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
boltzd --standalone --referralId lnbits &
|
||||||
|
|
||||||
|
# Capture boltzd PID to monitor if needed
|
||||||
|
BOLTZ_PID=$!
|
||||||
|
|
||||||
|
# Wait for boltzd to start
|
||||||
|
for i in {1..10}; do
|
||||||
|
if nc -z localhost 9002; then
|
||||||
|
echo "boltzd is up!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for boltzd to start..."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Optional: check if still not up
|
||||||
|
if ! nc -z localhost 9002; then
|
||||||
|
echo "boltzd did not start successfully."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
||||||
|
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# LNbits Funding Sources Comparison Table
|
||||||
|
|
||||||
|
LNbits can use a number of different Lightning Network funding source.
|
||||||
|
|
||||||
|
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
|
||||||
|
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
|
||||||
|
|
||||||
|
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
|
||||||
|
|
||||||
|
## 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** |
|
||||||
|
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||||
@@ -18,7 +18,7 @@ Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppI
|
|||||||
sudo apt-get install libfuse2
|
sudo apt-get install libfuse2
|
||||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||||
chmod +x LNbits-latest.AppImage
|
chmod +x LNbits-latest.AppImage
|
||||||
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
|
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 folder the AppImage runs from.
|
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||||
@@ -78,11 +78,24 @@ poetry run lnbits-cli --help
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd lnbits
|
cd lnbits
|
||||||
# Stop LNbits with `ctrl + x`
|
# Stop LNbits with `ctrl + x` or with service manager
|
||||||
git pull
|
# sudo systemctl stop lnbits
|
||||||
# Keep your poetry install up to date, this can be done with `poetry self update`
|
|
||||||
|
# Update LNbits
|
||||||
|
git pull --rebase
|
||||||
|
|
||||||
|
# Check your poetry version with
|
||||||
|
poetry env list
|
||||||
|
# If version is less 3.12, update it by running
|
||||||
|
poetry env use python3.12
|
||||||
|
poetry env remove python3.9
|
||||||
|
poetry env list
|
||||||
|
|
||||||
|
# Run install and start LNbits with
|
||||||
poetry install --only main
|
poetry install --only main
|
||||||
# Start LNbits with `poetry run lnbits`
|
poetry run lnbits
|
||||||
|
|
||||||
|
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option 2: Install script (on Debian/Ubuntu)
|
## Option 2: Install script (on Debian/Ubuntu)
|
||||||
@@ -137,7 +150,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
|||||||
|
|
||||||
## Option 4: Docker
|
## Option 4: Docker
|
||||||
|
|
||||||
use latest version from docker hub
|
Use latest version from Docker Hub.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull lnbits/lnbits
|
docker pull lnbits/lnbits
|
||||||
@@ -146,7 +159,15 @@ mkdir data
|
|||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
build the image yourself
|
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
|
||||||
|
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the image yourself.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
@@ -157,6 +178,12 @@ mkdir data
|
|||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||||
|
```
|
||||||
|
|
||||||
## Option 5: Fly.io
|
## Option 5: Fly.io
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -564,3 +591,12 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally you can access your lnbits on your machine at port 5000.
|
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
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ LNbits can run on top of many Lightning Network funding sources with more being
|
|||||||
|
|
||||||
A backend wallet can be configured using the following LNbits environment variables:
|
A backend wallet can be configured using the following LNbits environment variables:
|
||||||
|
|
||||||
|
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||||
|
|
||||||
### CoreLightning
|
### CoreLightning
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
@@ -51,7 +53,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
|||||||
|
|
||||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||||
|
|
||||||
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
|
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
|
||||||
|
|
||||||
### LNbits
|
### LNbits
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ if [ ! -d lnbits/data ]; then
|
|||||||
# Add the deadsnakes PPA repository non-interactively
|
# Add the deadsnakes PPA repository non-interactively
|
||||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||||
|
|
||||||
# Install Python 3.9 and distutils non-interactively
|
# Install Python 3.10 and distutils non-interactively
|
||||||
sudo apt install -y python3.9 python3.9-distutils
|
sudo apt install -y python3.10 python3.10-distutils
|
||||||
|
|
||||||
# Install Poetry
|
# Install Poetry
|
||||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
curl -sSL https://install.python-poetry.org | python3.10 -
|
||||||
|
|
||||||
# Add Poetry to PATH for the current session
|
# Add Poetry to PATH for the current session
|
||||||
export PATH="/home/$USER/.local/bin:$PATH"
|
export PATH="/home/$USER/.local/bin:$PATH"
|
||||||
|
|||||||
+5
-8
@@ -9,16 +9,13 @@ from .decorators import (
|
|||||||
from .exceptions import InvoiceError, PaymentError
|
from .exceptions import InvoiceError, PaymentError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# decorators
|
"InvoiceError",
|
||||||
"require_admin_key",
|
"PaymentError",
|
||||||
"require_invoice_key",
|
|
||||||
"check_admin",
|
"check_admin",
|
||||||
"check_super_user",
|
"check_super_user",
|
||||||
"check_user_exists",
|
"check_user_exists",
|
||||||
# services
|
|
||||||
"pay_invoice",
|
|
||||||
"create_invoice",
|
"create_invoice",
|
||||||
# exceptions
|
"pay_invoice",
|
||||||
"PaymentError",
|
"require_admin_key",
|
||||||
"InvoiceError",
|
"require_invoice_key",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ from .middleware import (
|
|||||||
add_ip_block_middleware,
|
add_ip_block_middleware,
|
||||||
add_ratelimit_middleware,
|
add_ratelimit_middleware,
|
||||||
)
|
)
|
||||||
from .requestvars import g
|
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
internal_invoice_listener,
|
internal_invoice_listener,
|
||||||
@@ -171,8 +170,6 @@ def create_app() -> FastAPI:
|
|||||||
name="library",
|
name="library",
|
||||||
)
|
)
|
||||||
|
|
||||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import importlib
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from getpass import getpass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -40,7 +41,9 @@ from lnbits.core.views.extension_api import (
|
|||||||
api_uninstall_extension,
|
api_uninstall_extension,
|
||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.crypto import AESCipher
|
||||||
from lnbits.wallets.base import Wallet
|
from lnbits.wallets.base import Wallet
|
||||||
|
from lnbits.wallets.macaroon import load_macaroon
|
||||||
|
|
||||||
|
|
||||||
def coro(f):
|
def coro(f):
|
||||||
@@ -79,6 +82,20 @@ def extensions():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@lnbits_cli.group()
|
||||||
|
def encrypt():
|
||||||
|
"""
|
||||||
|
Encryption commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@lnbits_cli.group()
|
||||||
|
def decrypt():
|
||||||
|
"""
|
||||||
|
Decryption commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_super_user() -> Optional[str]:
|
def get_super_user() -> Optional[str]:
|
||||||
"""Get the superuser"""
|
"""Get the superuser"""
|
||||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||||
@@ -479,6 +496,62 @@ async def extensions_uninstall(
|
|||||||
return False, str(ex)
|
return False, str(ex)
|
||||||
|
|
||||||
|
|
||||||
|
@encrypt.command("macaroon")
|
||||||
|
def encrypt_macaroon():
|
||||||
|
"""Encrypts a macaroon (LND wallets)"""
|
||||||
|
_macaroon = getpass("Enter macaroon: ")
|
||||||
|
try:
|
||||||
|
macaroon = load_macaroon(_macaroon)
|
||||||
|
except Exception as ex:
|
||||||
|
click.echo(f"Error loading macaroon: {ex}")
|
||||||
|
return
|
||||||
|
key = getpass("Enter encryption key: ")
|
||||||
|
aes = AESCipher(key.encode())
|
||||||
|
try:
|
||||||
|
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
|
||||||
|
except Exception as ex:
|
||||||
|
click.echo(f"Error encrypting macaroon: {ex}")
|
||||||
|
return
|
||||||
|
click.echo("Encrypted macaroon: ")
|
||||||
|
click.echo(encrypted_macaroon)
|
||||||
|
|
||||||
|
|
||||||
|
@encrypt.command("aes")
|
||||||
|
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||||
|
@click.option(
|
||||||
|
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
||||||
|
)
|
||||||
|
def encrypt_aes(payload: str, urlsafe: bool = False):
|
||||||
|
"""AES encrypts a payload"""
|
||||||
|
key = getpass("Enter encryption key: ")
|
||||||
|
aes = AESCipher(key.encode())
|
||||||
|
try:
|
||||||
|
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||||
|
except Exception as ex:
|
||||||
|
click.echo(f"Error encrypting payload: {ex}")
|
||||||
|
return
|
||||||
|
click.echo("Encrypted payload: ")
|
||||||
|
click.echo(encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
@decrypt.command("aes")
|
||||||
|
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||||
|
@click.option(
|
||||||
|
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
||||||
|
)
|
||||||
|
def decrypt_aes(payload: str, urlsafe: bool = False):
|
||||||
|
"""AES decrypts a payload"""
|
||||||
|
key = getpass("Enter encryption key: ")
|
||||||
|
aes = AESCipher(key.encode())
|
||||||
|
try:
|
||||||
|
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||||
|
except Exception as ex:
|
||||||
|
click.echo(f"Error decrypting payload: {ex}")
|
||||||
|
return
|
||||||
|
click.echo("Decrypted payload: ")
|
||||||
|
click.echo(decrypted)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""main function"""
|
"""main function"""
|
||||||
lnbits_cli()
|
lnbits_cli()
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from .views.admin_api import admin_router
|
|||||||
from .views.api import api_router
|
from .views.api import api_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.extension_api import extension_router
|
from .views.extension_api import extension_router
|
||||||
|
from .views.fiat_api import fiat_router
|
||||||
|
|
||||||
# this compat is needed for usermanager extension
|
# this compat is needed for usermanager extension
|
||||||
from .views.generic import generic_router
|
from .views.generic import generic_router
|
||||||
@@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
|
|||||||
app.include_router(wallet_router)
|
app.include_router(wallet_router)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
|
app.include_router(callback_router)
|
||||||
app.include_router(tinyurl_router)
|
app.include_router(tinyurl_router)
|
||||||
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(fiat_router)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["core_app", "core_app_extra", "db"]
|
__all__ = ["core_app", "core_app_extra", "db"]
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from .settings import (
|
|||||||
delete_admin_settings,
|
delete_admin_settings,
|
||||||
get_admin_settings,
|
get_admin_settings,
|
||||||
get_super_settings,
|
get_super_settings,
|
||||||
|
reset_core_settings,
|
||||||
update_admin_settings,
|
update_admin_settings,
|
||||||
update_super_user,
|
update_super_user,
|
||||||
)
|
)
|
||||||
@@ -84,86 +85,78 @@ from .webpush import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# audit
|
|
||||||
"create_audit_entry",
|
|
||||||
# db_versions
|
|
||||||
"get_db_version",
|
|
||||||
"get_db_versions",
|
|
||||||
"update_migration_version",
|
|
||||||
"delete_dbversion",
|
|
||||||
# extensions
|
|
||||||
"create_installed_extension",
|
|
||||||
"create_user_extension",
|
|
||||||
"delete_installed_extension",
|
|
||||||
"drop_extension_db",
|
|
||||||
"get_installed_extension",
|
|
||||||
"get_installed_extensions",
|
|
||||||
"get_user_active_extensions_ids",
|
|
||||||
"get_user_extension",
|
|
||||||
"update_installed_extension",
|
|
||||||
"update_installed_extension_state",
|
|
||||||
"update_user_extension",
|
|
||||||
"get_user_extensions",
|
|
||||||
# payments
|
|
||||||
"DateTrunc",
|
"DateTrunc",
|
||||||
"check_internal",
|
"check_internal",
|
||||||
"create_payment",
|
|
||||||
"delete_expired_invoices",
|
|
||||||
"delete_wallet_payment",
|
|
||||||
"get_latest_payments_by_extension",
|
|
||||||
"get_payment",
|
|
||||||
"get_payments",
|
|
||||||
"get_payments_history",
|
|
||||||
"get_payments_paginated",
|
|
||||||
"get_standalone_payment",
|
|
||||||
"get_wallet_payment",
|
|
||||||
"is_internal_status_success",
|
|
||||||
"mark_webhook_sent",
|
|
||||||
"update_payment",
|
|
||||||
"update_payment_checking_id",
|
|
||||||
"update_payment_extra",
|
|
||||||
# settings
|
|
||||||
"create_admin_settings",
|
|
||||||
"delete_admin_settings",
|
|
||||||
"get_admin_settings",
|
|
||||||
"get_super_settings",
|
|
||||||
"update_admin_settings",
|
|
||||||
"update_super_user",
|
|
||||||
# tinyurl
|
|
||||||
"create_tinyurl",
|
|
||||||
"delete_tinyurl",
|
|
||||||
"get_tinyurl",
|
|
||||||
"get_tinyurl_by_url",
|
|
||||||
# users
|
|
||||||
"create_account",
|
"create_account",
|
||||||
|
"create_admin_settings",
|
||||||
|
"create_audit_entry",
|
||||||
|
"create_installed_extension",
|
||||||
|
"create_payment",
|
||||||
|
"create_tinyurl",
|
||||||
|
"create_user_extension",
|
||||||
|
"create_wallet",
|
||||||
|
"create_webpush_subscription",
|
||||||
"delete_account",
|
"delete_account",
|
||||||
"delete_accounts_no_wallets",
|
"delete_accounts_no_wallets",
|
||||||
|
"delete_admin_settings",
|
||||||
|
"delete_dbversion",
|
||||||
|
"delete_expired_invoices",
|
||||||
|
"delete_installed_extension",
|
||||||
|
"delete_tinyurl",
|
||||||
|
"delete_unused_wallets",
|
||||||
|
"delete_wallet",
|
||||||
|
"delete_wallet_by_id",
|
||||||
|
"delete_wallet_payment",
|
||||||
|
"delete_webpush_subscription",
|
||||||
|
"delete_webpush_subscriptions",
|
||||||
|
"drop_extension_db",
|
||||||
|
"force_delete_wallet",
|
||||||
"get_account",
|
"get_account",
|
||||||
"get_account_by_email",
|
"get_account_by_email",
|
||||||
"get_account_by_pubkey",
|
"get_account_by_pubkey",
|
||||||
"get_account_by_username",
|
"get_account_by_username",
|
||||||
"get_account_by_username_or_email",
|
"get_account_by_username_or_email",
|
||||||
"get_accounts",
|
"get_accounts",
|
||||||
"get_user",
|
"get_admin_settings",
|
||||||
"get_user_from_account",
|
"get_db_version",
|
||||||
"get_user_access_control_lists",
|
"get_db_versions",
|
||||||
"update_account",
|
"get_installed_extension",
|
||||||
# wallets
|
"get_installed_extensions",
|
||||||
"create_wallet",
|
"get_latest_payments_by_extension",
|
||||||
"delete_unused_wallets",
|
"get_payment",
|
||||||
"delete_wallet",
|
"get_payments",
|
||||||
"delete_wallet_by_id",
|
"get_payments_history",
|
||||||
"force_delete_wallet",
|
"get_payments_paginated",
|
||||||
|
"get_standalone_payment",
|
||||||
|
"get_super_settings",
|
||||||
|
"get_tinyurl",
|
||||||
|
"get_tinyurl_by_url",
|
||||||
"get_total_balance",
|
"get_total_balance",
|
||||||
|
"get_user",
|
||||||
|
"get_user_access_control_lists",
|
||||||
|
"get_user_active_extensions_ids",
|
||||||
|
"get_user_extension",
|
||||||
|
"get_user_extensions",
|
||||||
|
"get_user_from_account",
|
||||||
"get_wallet",
|
"get_wallet",
|
||||||
"get_wallet_for_key",
|
"get_wallet_for_key",
|
||||||
|
"get_wallet_payment",
|
||||||
"get_wallets",
|
"get_wallets",
|
||||||
"remove_deleted_wallets",
|
|
||||||
"update_wallet",
|
|
||||||
# webpush
|
|
||||||
"create_webpush_subscription",
|
|
||||||
"delete_webpush_subscription",
|
|
||||||
"delete_webpush_subscriptions",
|
|
||||||
"get_webpush_subscription",
|
"get_webpush_subscription",
|
||||||
"get_webpush_subscriptions_for_user",
|
"get_webpush_subscriptions_for_user",
|
||||||
|
"is_internal_status_success",
|
||||||
|
"mark_webhook_sent",
|
||||||
|
"remove_deleted_wallets",
|
||||||
|
"reset_core_settings",
|
||||||
|
"update_account",
|
||||||
|
"update_admin_settings",
|
||||||
|
"update_installed_extension",
|
||||||
|
"update_installed_extension_state",
|
||||||
|
"update_migration_version",
|
||||||
|
"update_payment",
|
||||||
|
"update_payment_checking_id",
|
||||||
|
"update_payment_extra",
|
||||||
|
"update_super_user",
|
||||||
|
"update_user_extension",
|
||||||
|
"update_wallet",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from time import time
|
from time import time
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet
|
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentState
|
from lnbits.core.models import PaymentState
|
||||||
from lnbits.db import Connection, DateTrunc, Filters, Page
|
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||||
@@ -95,6 +95,7 @@ async def get_latest_payments_by_extension(
|
|||||||
async def get_payments_paginated(
|
async def get_payments_paginated(
|
||||||
*,
|
*,
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
complete: bool = False,
|
complete: bool = False,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
failed: bool = False,
|
failed: bool = False,
|
||||||
@@ -121,6 +122,9 @@ async def get_payments_paginated(
|
|||||||
if wallet_id:
|
if wallet_id:
|
||||||
values["wallet_id"] = wallet_id
|
values["wallet_id"] = wallet_id
|
||||||
clause.append("wallet_id = :wallet_id")
|
clause.append("wallet_id = :wallet_id")
|
||||||
|
elif user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
clause.append(only_user_wallets)
|
||||||
|
|
||||||
if complete and pending:
|
if complete and pending:
|
||||||
clause.append(
|
clause.append(
|
||||||
@@ -358,12 +362,19 @@ async def get_payments_history(
|
|||||||
async def get_payment_count_stats(
|
async def get_payment_count_stats(
|
||||||
field: PaymentCountField,
|
field: PaymentCountField,
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[PaymentCountStat]:
|
) -> list[PaymentCountStat]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where()
|
extra_stmts = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
extra_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
|
clause = filters.where(extra_stmts)
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT {field} as field, count(*) as total
|
SELECT {field} as field, count(*) as total
|
||||||
@@ -381,18 +392,26 @@ async def get_payment_count_stats(
|
|||||||
|
|
||||||
async def get_daily_stats(
|
async def get_daily_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
|
|
||||||
in_clause = filters.where(
|
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||||
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
out_where_stmts = [
|
||||||
)
|
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
|
||||||
out_clause = filters.where(
|
]
|
||||||
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
|
|
||||||
)
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
in_where_stmts.append(only_user_wallets)
|
||||||
|
out_where_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
|
in_clause = filters.where(in_where_stmts)
|
||||||
|
out_clause = filters.where(out_where_stmts)
|
||||||
|
|
||||||
date_trunc = db.datetime_grouping("day")
|
date_trunc = db.datetime_grouping("day")
|
||||||
query = """
|
query = """
|
||||||
SELECT {date_trunc} date,
|
SELECT {date_trunc} date,
|
||||||
@@ -423,6 +442,7 @@ async def get_daily_stats(
|
|||||||
|
|
||||||
async def get_wallets_stats(
|
async def get_wallets_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[PaymentWalletStats]:
|
) -> list[PaymentWalletStats]:
|
||||||
|
|
||||||
@@ -441,6 +461,10 @@ async def get_wallets_stats(
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
where_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
clauses = filters.where(where_stmts)
|
clauses = filters.where(where_stmts)
|
||||||
|
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
@@ -508,7 +532,7 @@ async def is_internal_status_success(
|
|||||||
return payment.status == PaymentState.SUCCESS.value
|
return payment.status == PaymentState.SUCCESS.value
|
||||||
|
|
||||||
|
|
||||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE apipayments SET webhook_status = :status
|
UPDATE apipayments SET webhook_status = :status
|
||||||
@@ -516,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
|||||||
""",
|
""",
|
||||||
{"status": status, "hash": payment_hash},
|
{"status": status, "hash": payment_hash},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _only_user_wallets_statement(
|
||||||
|
user_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> str:
|
||||||
|
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
||||||
|
"no-wallets-for-user"
|
||||||
|
]
|
||||||
|
# wallet ids are safe to use in sql queries
|
||||||
|
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
||||||
|
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
|
||||||
|
|||||||
@@ -61,7 +61,23 @@ async def update_super_user(super_user: str) -> SuperSettings:
|
|||||||
|
|
||||||
|
|
||||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||||
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
|
await db.execute(
|
||||||
|
"DELETE FROM system_settings WHERE tag = :tag",
|
||||||
|
{"tag": tag},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_core_settings() -> None:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM system_settings WHERE tag = 'core'
|
||||||
|
AND id NOT IN (
|
||||||
|
'super_user',
|
||||||
|
'lnbits_webpush_pubkey',
|
||||||
|
'lnbits_webpush_privkey'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async def get_accounts(
|
|||||||
accounts.username,
|
accounts.username,
|
||||||
accounts.email,
|
accounts.email,
|
||||||
accounts.pubkey,
|
accounts.pubkey,
|
||||||
|
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
|
||||||
), 0)) as balance_msat,
|
), 0)) as balance_msat,
|
||||||
@@ -128,8 +129,8 @@ async def get_account_by_username(
|
|||||||
if len(username) == 0:
|
if len(username) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE username = :username",
|
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||||
{"username": username},
|
{"username": username.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,8 +139,8 @@ async def get_account_by_pubkey(
|
|||||||
pubkey: str, conn: Optional[Connection] = None
|
pubkey: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Account]:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||||
{"pubkey": pubkey},
|
{"pubkey": pubkey.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,8 +151,8 @@ async def get_account_by_email(
|
|||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE email = :email",
|
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||||
{"email": email},
|
{"email": email.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,8 +161,11 @@ async def get_account_by_username_or_email(
|
|||||||
username_or_email: str, conn: Optional[Connection] = None
|
username_or_email: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Account]:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
"""
|
||||||
{"value": username_or_email},
|
SELECT * FROM accounts
|
||||||
|
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||||
|
""",
|
||||||
|
{"value": username_or_email.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,6 +187,7 @@ async def get_user_from_account(
|
|||||||
email=account.email,
|
email=account.email,
|
||||||
username=account.username,
|
username=account.username,
|
||||||
pubkey=account.pubkey,
|
pubkey=account.pubkey,
|
||||||
|
external_id=account.external_id,
|
||||||
extra=account.extra,
|
extra=account.extra,
|
||||||
created_at=account.created_at,
|
created_at=account.created_at,
|
||||||
updated_at=account.updated_at,
|
updated_at=account.updated_at,
|
||||||
@@ -190,6 +195,7 @@ async def get_user_from_account(
|
|||||||
wallets=wallets,
|
wallets=wallets,
|
||||||
admin=account.is_admin,
|
admin=account.is_admin,
|
||||||
super_user=account.is_super_user,
|
super_user=account.is_super_user,
|
||||||
|
fiat_providers=account.fiat_providers,
|
||||||
has_password=account.password_hash is not None,
|
has_password=account.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from typing import Optional
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.db import Connection
|
from lnbits.core.models.wallets import WalletsFilters
|
||||||
|
from lnbits.db import Connection, Filters, Page
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..models import Wallet
|
from ..models import Wallet
|
||||||
@@ -135,6 +136,43 @@ async def get_wallets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wallets_paginated(
|
||||||
|
user_id: str,
|
||||||
|
deleted: Optional[bool] = None,
|
||||||
|
filters: Optional[Filters[WalletsFilters]] = None,
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
|
) -> Page[Wallet]:
|
||||||
|
if deleted is None:
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||||
|
return await (conn or db).fetch_page(
|
||||||
|
"""
|
||||||
|
SELECT *, COALESCE((
|
||||||
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
|
), 0) AS balance_msat FROM wallets
|
||||||
|
""",
|
||||||
|
where=where,
|
||||||
|
values={"user": user_id, "deleted": deleted},
|
||||||
|
filters=filters,
|
||||||
|
model=Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wallets_ids(
|
||||||
|
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||||
|
) -> list[str]:
|
||||||
|
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||||
|
result: list[dict] = await (conn or db).fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT id FROM wallets
|
||||||
|
WHERE "user" = :user {where}
|
||||||
|
""",
|
||||||
|
{"user": user_id, "deleted": deleted},
|
||||||
|
)
|
||||||
|
return [row["id"] for row in result]
|
||||||
|
|
||||||
|
|
||||||
async def get_wallets_count():
|
async def get_wallets_count():
|
||||||
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
|
|||||||
@@ -711,3 +711,15 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
|||||||
Adds icon and color columns to wallets.
|
Adds icon and color columns to wallets.
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
async def m032_add_external_id_to_accounts(db: Connection):
|
||||||
|
"""
|
||||||
|
Adds external_id column to accounts.
|
||||||
|
Used for external account linking.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
async def m033_update_payment_table(db: Connection):
|
||||||
|
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
||||||
|
|||||||
@@ -48,62 +48,54 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
|||||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# audit
|
|
||||||
"AuditEntry",
|
|
||||||
"AuditFilters",
|
|
||||||
# lnurl
|
|
||||||
"CreateLnurl",
|
|
||||||
"CreateLnurlAuth",
|
|
||||||
"PayLnurlWData",
|
|
||||||
# misc
|
|
||||||
"BalanceDelta",
|
|
||||||
"Callback",
|
|
||||||
"ConversionData",
|
|
||||||
"CoreAppExtra",
|
|
||||||
"DbVersion",
|
|
||||||
"SimpleStatus",
|
|
||||||
# payments
|
|
||||||
"CreateInvoice",
|
|
||||||
"CreatePayment",
|
|
||||||
"DecodePayment",
|
|
||||||
"PayInvoice",
|
|
||||||
"Payment",
|
|
||||||
"PaymentCountField",
|
|
||||||
"PaymentCountStat",
|
|
||||||
"PaymentDailyStats",
|
|
||||||
"PaymentsStatusCount",
|
|
||||||
"PaymentWalletStats",
|
|
||||||
"PaymentExtra",
|
|
||||||
"PaymentFilters",
|
|
||||||
"PaymentHistoryPoint",
|
|
||||||
"PaymentState",
|
|
||||||
# tinyurl
|
|
||||||
"TinyURL",
|
|
||||||
# users
|
|
||||||
"AccessTokenPayload",
|
"AccessTokenPayload",
|
||||||
"Account",
|
"Account",
|
||||||
"AccountFilters",
|
"AccountFilters",
|
||||||
"AccountOverview",
|
"AccountOverview",
|
||||||
"UserAcls",
|
"AuditEntry",
|
||||||
|
"AuditFilters",
|
||||||
|
"BalanceDelta",
|
||||||
|
"BaseWallet",
|
||||||
|
"Callback",
|
||||||
|
"ConversionData",
|
||||||
|
"CoreAppExtra",
|
||||||
|
"CreateInvoice",
|
||||||
|
"CreateLnurl",
|
||||||
|
"CreateLnurlAuth",
|
||||||
|
"CreatePayment",
|
||||||
"CreateUser",
|
"CreateUser",
|
||||||
"RegisterUser",
|
"CreateWallet",
|
||||||
|
"CreateWebPushSubscription",
|
||||||
|
"DbVersion",
|
||||||
|
"DecodePayment",
|
||||||
|
"KeyType",
|
||||||
"LoginUsernamePassword",
|
"LoginUsernamePassword",
|
||||||
"LoginUsr",
|
"LoginUsr",
|
||||||
|
"PayInvoice",
|
||||||
|
"PayLnurlWData",
|
||||||
|
"Payment",
|
||||||
|
"PaymentCountField",
|
||||||
|
"PaymentCountStat",
|
||||||
|
"PaymentDailyStats",
|
||||||
|
"PaymentExtra",
|
||||||
|
"PaymentFilters",
|
||||||
|
"PaymentHistoryPoint",
|
||||||
|
"PaymentState",
|
||||||
|
"PaymentWalletStats",
|
||||||
|
"PaymentsStatusCount",
|
||||||
|
"RegisterUser",
|
||||||
"ResetUserPassword",
|
"ResetUserPassword",
|
||||||
|
"SimpleStatus",
|
||||||
|
"TinyURL",
|
||||||
"UpdateBalance",
|
"UpdateBalance",
|
||||||
"UpdateSuperuserPassword",
|
"UpdateSuperuserPassword",
|
||||||
"UpdateUser",
|
"UpdateUser",
|
||||||
"UpdateUserPassword",
|
"UpdateUserPassword",
|
||||||
"UpdateUserPubkey",
|
"UpdateUserPubkey",
|
||||||
"User",
|
"User",
|
||||||
|
"UserAcls",
|
||||||
"UserExtra",
|
"UserExtra",
|
||||||
# wallets
|
|
||||||
"BaseWallet",
|
|
||||||
"CreateWallet",
|
|
||||||
"KeyType",
|
|
||||||
"Wallet",
|
"Wallet",
|
||||||
"WalletTypeInfo",
|
"WalletTypeInfo",
|
||||||
# webpush
|
|
||||||
"CreateWebPushSubscription",
|
|
||||||
"WebPushSubscription",
|
"WebPushSubscription",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ from fastapi import Query
|
|||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
|
from lnbits.fiat import get_fiat_provider
|
||||||
|
from lnbits.fiat.base import (
|
||||||
|
FiatPaymentFailedStatus,
|
||||||
|
FiatPaymentPendingStatus,
|
||||||
|
FiatPaymentStatus,
|
||||||
|
FiatPaymentSuccessStatus,
|
||||||
|
)
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
from lnbits.wallets.base import (
|
from lnbits.wallets.base import (
|
||||||
@@ -60,11 +67,13 @@ class Payment(BaseModel):
|
|||||||
amount: int
|
amount: int
|
||||||
fee: int
|
fee: int
|
||||||
bolt11: str
|
bolt11: str
|
||||||
|
# payment_request: str | None
|
||||||
|
fiat_provider: str | None = None
|
||||||
status: str = PaymentState.PENDING
|
status: str = PaymentState.PENDING
|
||||||
memo: str | None = None
|
memo: str | None = None
|
||||||
expiry: datetime | None = None
|
expiry: datetime | None = None
|
||||||
webhook: str | None = None
|
webhook: str | None = None
|
||||||
webhook_status: int | None = None
|
webhook_status: str | None = None
|
||||||
preimage: str | None = None
|
preimage: str | None = None
|
||||||
tag: str | None = None
|
tag: str | None = None
|
||||||
extension: str | None = None
|
extension: str | None = None
|
||||||
@@ -107,14 +116,23 @@ class Payment(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_internal(self) -> bool:
|
def is_internal(self) -> bool:
|
||||||
return self.checking_id.startswith("internal_")
|
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
|
||||||
|
"fiat_"
|
||||||
|
)
|
||||||
|
|
||||||
async def check_status(self) -> PaymentStatus:
|
async def check_status(
|
||||||
|
self, skip_internal_payment_notifications: bool | None = False
|
||||||
|
) -> PaymentStatus:
|
||||||
if self.is_internal:
|
if self.is_internal:
|
||||||
if self.success:
|
if self.success:
|
||||||
return PaymentSuccessStatus()
|
return PaymentSuccessStatus()
|
||||||
if self.failed:
|
if self.failed:
|
||||||
return PaymentFailedStatus()
|
return PaymentFailedStatus()
|
||||||
|
if self.is_in and self.fiat_provider:
|
||||||
|
fiat_status = await self.check_fiat_status(
|
||||||
|
skip_internal_payment_notifications
|
||||||
|
)
|
||||||
|
return PaymentStatus(paid=fiat_status.paid)
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
funding_source = get_funding_source()
|
funding_source = get_funding_source()
|
||||||
if self.is_out:
|
if self.is_out:
|
||||||
@@ -123,6 +141,39 @@ class Payment(BaseModel):
|
|||||||
status = await funding_source.get_invoice_status(self.checking_id)
|
status = await funding_source.get_invoice_status(self.checking_id)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
async def check_fiat_status(
|
||||||
|
self, skip_internal_payment_notifications: bool | None = False
|
||||||
|
) -> FiatPaymentStatus:
|
||||||
|
if not self.is_internal:
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
if self.success:
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
if self.failed:
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
if not self.fiat_provider:
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
checking_id = self.extra.get("fiat_checking_id")
|
||||||
|
if not checking_id:
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
fiat_provider = await get_fiat_provider(self.fiat_provider)
|
||||||
|
if not fiat_provider:
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||||
|
|
||||||
|
if skip_internal_payment_notifications:
|
||||||
|
return fiat_status
|
||||||
|
|
||||||
|
if fiat_status.success:
|
||||||
|
# notify receivers asynchronously
|
||||||
|
from lnbits.tasks import internal_invoice_queue
|
||||||
|
|
||||||
|
await internal_invoice_queue.put(self.checking_id)
|
||||||
|
|
||||||
|
return fiat_status
|
||||||
|
|
||||||
|
|
||||||
class PaymentFilters(FilterModel):
|
class PaymentFilters(FilterModel):
|
||||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||||
@@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
|
|||||||
webhook: str | None = None
|
webhook: str | None = None
|
||||||
bolt11: str | None = None
|
bolt11: str | None = None
|
||||||
lnurl_callback: str | None = None
|
lnurl_callback: str | None = None
|
||||||
|
fiat_provider: str | None = None
|
||||||
|
|
||||||
@validator("unit")
|
@validator("unit")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Keycloak SSO Login Helper
|
"""Keycloak SSO Login Helper"""
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from lnbits.core.models.misc import SimpleItem
|
from lnbits.core.models.misc import SimpleItem
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
from lnbits.helpers import (
|
||||||
|
is_valid_email_address,
|
||||||
|
is_valid_external_id,
|
||||||
|
is_valid_pubkey,
|
||||||
|
is_valid_username,
|
||||||
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .wallets import Wallet
|
from .wallets import Wallet
|
||||||
@@ -27,6 +32,9 @@ class UserExtra(BaseModel):
|
|||||||
# - "google | github | ...": the user was created using an SSO provider
|
# - "google | github | ...": the user was created using an SSO provider
|
||||||
provider: str | None = "lnbits" # auth provider
|
provider: str | None = "lnbits" # auth provider
|
||||||
|
|
||||||
|
# how many wallets are shown in the user interface
|
||||||
|
visible_wallet_count: int | None = 10
|
||||||
|
|
||||||
|
|
||||||
class EndpointAccess(BaseModel):
|
class EndpointAccess(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
@@ -90,6 +98,7 @@ class UserAcls(BaseModel):
|
|||||||
|
|
||||||
class Account(BaseModel):
|
class Account(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
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
|
||||||
@@ -101,11 +110,13 @@ class Account(BaseModel):
|
|||||||
|
|
||||||
is_super_user: bool = Field(default=False, no_database=True)
|
is_super_user: bool = Field(default=False, no_database=True)
|
||||||
is_admin: bool = Field(default=False, no_database=True)
|
is_admin: bool = Field(default=False, no_database=True)
|
||||||
|
fiat_providers: list[str] = Field(default=[], no_database=True)
|
||||||
|
|
||||||
def __init__(self, **data):
|
def __init__(self, **data):
|
||||||
super().__init__(**data)
|
super().__init__(**data)
|
||||||
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)
|
||||||
|
|
||||||
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"""
|
||||||
@@ -127,6 +138,11 @@ class Account(BaseModel):
|
|||||||
raise ValueError("Invalid email.")
|
raise ValueError("Invalid email.")
|
||||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||||
raise ValueError("Invalid pubkey.")
|
raise ValueError("Invalid pubkey.")
|
||||||
|
if self.external_id and not is_valid_external_id(self.external_id):
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid external id. Max length is 256 characters. "
|
||||||
|
"Space and newlines are not allowed."
|
||||||
|
)
|
||||||
user_uuid4 = UUID(hex=self.id, version=4)
|
user_uuid4 = UUID(hex=self.id, version=4)
|
||||||
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.")
|
||||||
@@ -140,7 +156,14 @@ class AccountOverview(Account):
|
|||||||
|
|
||||||
|
|
||||||
class AccountFilters(FilterModel):
|
class AccountFilters(FilterModel):
|
||||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
__search_fields__ = [
|
||||||
|
"user",
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"pubkey",
|
||||||
|
"external_id",
|
||||||
|
"wallet_id",
|
||||||
|
]
|
||||||
__sort_fields__ = [
|
__sort_fields__ = [
|
||||||
"balance_msat",
|
"balance_msat",
|
||||||
"email",
|
"email",
|
||||||
@@ -154,6 +177,7 @@ class AccountFilters(FilterModel):
|
|||||||
user: str | None = None
|
user: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
wallet_id: str | None = None
|
wallet_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -164,10 +188,12 @@ class User(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
|
external_id: str | None = None # for external account linking
|
||||||
extensions: list[str] = []
|
extensions: list[str] = []
|
||||||
wallets: list[Wallet] = []
|
wallets: list[Wallet] = []
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
super_user: bool = False
|
super_user: bool = False
|
||||||
|
fiat_providers: list[str] = []
|
||||||
has_password: bool = False
|
has_password: bool = False
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
|
||||||
@@ -204,13 +230,13 @@ class CreateUser(BaseModel):
|
|||||||
password: str | None = Query(default=None, min_length=8, max_length=50)
|
password: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
pubkey: str = Query(default=None, max_length=64)
|
pubkey: str = Query(default=None, max_length=64)
|
||||||
|
external_id: str = Query(default=None, max_length=256)
|
||||||
extensions: list[str] | None = None
|
extensions: list[str] | None = None
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateUser(BaseModel):
|
class UpdateUser(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
email: str | None = Query(default=None)
|
|
||||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from enum import Enum
|
|||||||
from ecdsa import SECP256k1, SigningKey
|
from ecdsa import SECP256k1, SigningKey
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.lnurl import encode as lnurl_encode
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
|
|||||||
class WalletExtra(BaseModel):
|
class WalletExtra(BaseModel):
|
||||||
icon: str = "flash_on"
|
icon: str = "flash_on"
|
||||||
color: str = "primary"
|
color: str = "primary"
|
||||||
|
pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Wallet(BaseModel):
|
class Wallet(BaseModel):
|
||||||
@@ -83,3 +85,13 @@ class KeyType(Enum):
|
|||||||
class WalletTypeInfo:
|
class WalletTypeInfo:
|
||||||
key_type: KeyType
|
key_type: KeyType
|
||||||
wallet: Wallet
|
wallet: Wallet
|
||||||
|
|
||||||
|
|
||||||
|
class WalletsFilters(FilterModel):
|
||||||
|
__search_fields__ = ["id", "name", "currency"]
|
||||||
|
|
||||||
|
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
id: str | None
|
||||||
|
name: str | None
|
||||||
|
currency: str | None
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ from .payments import (
|
|||||||
calculate_fiat_amounts,
|
calculate_fiat_amounts,
|
||||||
check_transaction_status,
|
check_transaction_status,
|
||||||
check_wallet_limits,
|
check_wallet_limits,
|
||||||
|
create_fiat_invoice,
|
||||||
create_invoice,
|
create_invoice,
|
||||||
|
create_wallet_invoice,
|
||||||
fee_reserve,
|
fee_reserve,
|
||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
|
get_payments_daily_stats,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
service_fee,
|
service_fee,
|
||||||
|
update_pending_payment,
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
update_wallet_balance,
|
update_wallet_balance,
|
||||||
)
|
)
|
||||||
@@ -30,36 +34,33 @@ from .users import (
|
|||||||
from .websockets import websocket_manager, websocket_updater
|
from .websockets import websocket_manager, websocket_updater
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# funding source
|
|
||||||
"get_balance_delta",
|
|
||||||
"switch_to_voidwallet",
|
|
||||||
# lnurl
|
|
||||||
"redeem_lnurl_withdraw",
|
|
||||||
"perform_lnurlauth",
|
|
||||||
# notifications
|
|
||||||
"enqueue_notification",
|
|
||||||
"send_payment_notification",
|
|
||||||
# payments
|
|
||||||
"calculate_fiat_amounts",
|
"calculate_fiat_amounts",
|
||||||
|
"check_admin_settings",
|
||||||
"check_transaction_status",
|
"check_transaction_status",
|
||||||
"check_wallet_limits",
|
"check_wallet_limits",
|
||||||
"create_invoice",
|
|
||||||
"fee_reserve",
|
|
||||||
"fee_reserve_total",
|
|
||||||
"pay_invoice",
|
|
||||||
"service_fee",
|
|
||||||
"update_pending_payments",
|
|
||||||
"update_wallet_balance",
|
|
||||||
# settings
|
|
||||||
"check_webpush_settings",
|
"check_webpush_settings",
|
||||||
"update_cached_settings",
|
"create_fiat_invoice",
|
||||||
# users
|
"create_invoice",
|
||||||
"check_admin_settings",
|
|
||||||
"create_user_account",
|
"create_user_account",
|
||||||
"create_user_account_no_ckeck",
|
"create_user_account_no_ckeck",
|
||||||
|
"create_wallet_invoice",
|
||||||
|
"enqueue_notification",
|
||||||
|
"fee_reserve",
|
||||||
|
"fee_reserve_total",
|
||||||
|
"get_balance_delta",
|
||||||
|
"get_payments_daily_stats",
|
||||||
|
"pay_invoice",
|
||||||
|
"perform_lnurlauth",
|
||||||
|
"redeem_lnurl_withdraw",
|
||||||
|
"send_payment_notification",
|
||||||
|
"service_fee",
|
||||||
|
"switch_to_voidwallet",
|
||||||
|
"update_cached_settings",
|
||||||
|
"update_pending_payment",
|
||||||
|
"update_pending_payments",
|
||||||
"update_user_account",
|
"update_user_account",
|
||||||
"update_user_extensions",
|
"update_user_extensions",
|
||||||
# websockets
|
"update_wallet_balance",
|
||||||
"websocket_manager",
|
"websocket_manager",
|
||||||
"websocket_updater",
|
"websocket_updater",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
from lnbits.core.crud.payments import create_payment, get_standalone_payment
|
||||||
|
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||||
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
|
from lnbits.db import Connection
|
||||||
|
from lnbits.fiat import get_fiat_provider
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_fiat_payment_confirmation(
|
||||||
|
payment: Payment, conn: Optional[Connection] = None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _credit_fiat_service_fee_wallet(
|
||||||
|
payment: Payment, conn: Optional[Connection] = None
|
||||||
|
):
|
||||||
|
fiat_provider_name = payment.fiat_provider
|
||||||
|
if not fiat_provider_name:
|
||||||
|
return
|
||||||
|
if payment.fee == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||||
|
if not limits:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not limits.service_fee_wallet_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
memo = (
|
||||||
|
f"Service fee for fiat payment of "
|
||||||
|
f"{abs(payment.sat)} sats. "
|
||||||
|
f"Provider: {fiat_provider_name}. "
|
||||||
|
f"Wallet: '{payment.wallet_id}'."
|
||||||
|
)
|
||||||
|
create_payment_model = CreatePayment(
|
||||||
|
wallet_id=limits.service_fee_wallet_id,
|
||||||
|
bolt11=payment.bolt11,
|
||||||
|
payment_hash=payment.payment_hash,
|
||||||
|
amount_msat=abs(payment.fee),
|
||||||
|
memo=memo,
|
||||||
|
)
|
||||||
|
await create_payment(
|
||||||
|
checking_id=f"service_fee_{payment.payment_hash}",
|
||||||
|
data=create_payment_model,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _debit_fiat_service_faucet_wallet(
|
||||||
|
payment: Payment, conn: Optional[Connection] = None
|
||||||
|
):
|
||||||
|
fiat_provider_name = payment.fiat_provider
|
||||||
|
if not fiat_provider_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||||
|
if not limits:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not limits.service_faucet_wallet_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
||||||
|
if not faucet_wallet:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
memo = (
|
||||||
|
f"Faucet payment of {abs(payment.sat)} sats. "
|
||||||
|
f"Provider: {fiat_provider_name}. "
|
||||||
|
f"Wallet: '{payment.wallet_id}'."
|
||||||
|
)
|
||||||
|
create_payment_model = CreatePayment(
|
||||||
|
wallet_id=limits.service_faucet_wallet_id,
|
||||||
|
bolt11=payment.bolt11,
|
||||||
|
payment_hash=payment.payment_hash,
|
||||||
|
amount_msat=-abs(payment.amount),
|
||||||
|
memo=memo,
|
||||||
|
extra=payment.extra,
|
||||||
|
)
|
||||||
|
await create_payment(
|
||||||
|
checking_id=f"internal_fiat_{fiat_provider_name}_"
|
||||||
|
f"faucet_{payment.payment_hash}",
|
||||||
|
data=create_payment_model,
|
||||||
|
status=PaymentState.SUCCESS,
|
||||||
|
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: Optional[str],
|
||||||
|
secret: Optional[str],
|
||||||
|
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)
|
||||||
|
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}.",
|
||||||
|
)
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -48,7 +47,7 @@ async def send_nostr_dm(
|
|||||||
return dm_event.to_dict()
|
return dm_event.to_dict()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]:
|
async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]:
|
||||||
identifier, domain = identifier.split("@")
|
identifier, domain = identifier.split("@")
|
||||||
if not identifier or not domain:
|
if not identifier or not domain:
|
||||||
raise ValueError("Invalid NIP5 identifier")
|
raise ValueError("Invalid NIP5 identifier")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import smtplib
|
|||||||
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
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -186,7 +186,7 @@ def is_message_type_enabled(message_type: NotificationType) -> bool:
|
|||||||
|
|
||||||
def _notification_message_to_text(
|
def _notification_message_to_text(
|
||||||
notification_message: NotificationMessage,
|
notification_message: NotificationMessage,
|
||||||
) -> Tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
message_type = notification_message.message_type.value
|
message_type = notification_message.message_type.value
|
||||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||||
try:
|
try:
|
||||||
@@ -205,24 +205,23 @@ async def dispatch_webhook(payment: Payment):
|
|||||||
logger.debug("sending webhook", payment.webhook)
|
logger.debug("sending webhook", payment.webhook)
|
||||||
|
|
||||||
if not payment.webhook:
|
if not payment.webhook:
|
||||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
return await mark_webhook_sent(payment.payment_hash, "-1")
|
||||||
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
data = payment.dict()
|
|
||||||
try:
|
try:
|
||||||
check_callback_url(payment.webhook)
|
check_callback_url(payment.webhook)
|
||||||
r = await client.post(payment.webhook, json=data, 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, r.status_code)
|
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||||
f"while requesting {exc.request.url!r}."
|
f"while requesting {exc.request.url!r}."
|
||||||
)
|
)
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
await mark_webhook_sent(payment.payment_hash, -1)
|
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||||
|
|
||||||
|
|
||||||
@@ -230,18 +229,21 @@ 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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending websocket payment notification", e)
|
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||||
try:
|
try:
|
||||||
send_chat_payment_notification(wallet, payment)
|
send_chat_payment_notification(wallet, payment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending chat payment notification", e)
|
logger.error(f"Error sending chat payment notification {e!s}")
|
||||||
try:
|
try:
|
||||||
await send_payment_push_notification(wallet, payment)
|
await send_payment_push_notification(wallet, payment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending push payment notification", e)
|
logger.error(f"Error sending push payment notification {e!s}")
|
||||||
|
|
||||||
if payment.webhook and not payment.webhook_status:
|
try:
|
||||||
await dispatch_webhook(payment)
|
if payment.webhook and not payment.webhook_status:
|
||||||
|
await dispatch_webhook(payment)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dispatching webhook: {e!s}")
|
||||||
|
|
||||||
|
|
||||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from bolt11 import encode as bolt11_encode
|
from bolt11 import encode as bolt11_encode
|
||||||
@@ -11,11 +13,14 @@ from loguru import logger
|
|||||||
from lnbits.core.crud.payments import get_daily_stats
|
from lnbits.core.crud.payments import get_daily_stats
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||||
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.db import Connection, Filters
|
from lnbits.db import Connection, Filters
|
||||||
from lnbits.decorators import check_user_extension_access
|
from lnbits.decorators import check_user_extension_access
|
||||||
from lnbits.exceptions import InvoiceError, PaymentError
|
from lnbits.exceptions import InvoiceError, PaymentError
|
||||||
|
from lnbits.fiat import get_fiat_provider
|
||||||
|
from lnbits.helpers import check_callback_url
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.tasks import create_task
|
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
||||||
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
|
||||||
@@ -44,6 +49,9 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from .notifications import send_payment_notification
|
from .notifications import send_payment_notification
|
||||||
|
|
||||||
|
payment_lock = asyncio.Lock()
|
||||||
|
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
async def pay_invoice(
|
async def pay_invoice(
|
||||||
*,
|
*,
|
||||||
@@ -79,12 +87,132 @@ async def pay_invoice(
|
|||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||||
|
|
||||||
|
service_fee_memo = f"""
|
||||||
|
Service fee for payment of {abs(payment.sat)} sats.
|
||||||
|
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||||
|
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||||
await _credit_service_fee_wallet(payment, new_conn)
|
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
async def create_fiat_invoice(
|
||||||
|
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
|
||||||
|
):
|
||||||
|
fiat_provider_name = invoice_data.fiat_provider
|
||||||
|
if not fiat_provider_name:
|
||||||
|
raise ValueError("Fiat provider is required for fiat invoices.")
|
||||||
|
if not settings.is_fiat_provider_enabled(fiat_provider_name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' is not enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if invoice_data.unit == "sat":
|
||||||
|
raise ValueError("Fiat provider cannot be used with satoshis.")
|
||||||
|
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
|
||||||
|
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
|
||||||
|
|
||||||
|
invoice_data.internal = True # use FakeWallet for fiat invoices
|
||||||
|
if not invoice_data.memo:
|
||||||
|
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
|
||||||
|
|
||||||
|
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
|
||||||
|
|
||||||
|
fiat_provider = await get_fiat_provider(fiat_provider_name)
|
||||||
|
fiat_invoice = await fiat_provider.create_invoice(
|
||||||
|
amount=invoice_data.amount,
|
||||||
|
payment_hash=internal_payment.payment_hash,
|
||||||
|
currency=invoice_data.unit,
|
||||||
|
memo=invoice_data.memo,
|
||||||
|
)
|
||||||
|
if fiat_invoice.failed:
|
||||||
|
logger.warning(fiat_invoice.error_message)
|
||||||
|
internal_payment.status = PaymentState.FAILED
|
||||||
|
await update_payment(internal_payment, conn=conn)
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot create payment request for '{fiat_provider_name}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_payment.fee = -abs(
|
||||||
|
service_fee_fiat(internal_payment.msat, fiat_provider_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_payment.fiat_provider = fiat_provider_name
|
||||||
|
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||||
|
# todo: move to payent
|
||||||
|
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||||
|
new_checking_id = (
|
||||||
|
f"fiat_{fiat_provider_name}_"
|
||||||
|
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||||
|
)
|
||||||
|
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||||
|
internal_payment.checking_id = new_checking_id
|
||||||
|
|
||||||
|
return internal_payment
|
||||||
|
|
||||||
|
|
||||||
|
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||||
|
description_hash = b""
|
||||||
|
unhashed_description = b""
|
||||||
|
memo = data.memo or settings.lnbits_site_title
|
||||||
|
if data.description_hash or data.unhashed_description:
|
||||||
|
if data.description_hash:
|
||||||
|
try:
|
||||||
|
description_hash = bytes.fromhex(data.description_hash)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"'description_hash' must be a valid hex string"
|
||||||
|
) from exc
|
||||||
|
if data.unhashed_description:
|
||||||
|
try:
|
||||||
|
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"'unhashed_description' must be a valid hex string",
|
||||||
|
) from exc
|
||||||
|
# do not save memo if description_hash or unhashed_description is set
|
||||||
|
memo = ""
|
||||||
|
|
||||||
|
payment = await create_invoice(
|
||||||
|
wallet_id=wallet_id,
|
||||||
|
amount=data.amount,
|
||||||
|
memo=memo,
|
||||||
|
currency=data.unit,
|
||||||
|
description_hash=description_hash,
|
||||||
|
unhashed_description=unhashed_description,
|
||||||
|
expiry=data.expiry,
|
||||||
|
extra=data.extra,
|
||||||
|
webhook=data.webhook,
|
||||||
|
internal=data.internal,
|
||||||
|
)
|
||||||
|
|
||||||
|
# lnurl_response is not saved in the database
|
||||||
|
if data.lnurl_callback:
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
try:
|
||||||
|
check_callback_url(data.lnurl_callback)
|
||||||
|
r = await client.get(
|
||||||
|
data.lnurl_callback,
|
||||||
|
params={"pr": payment.bolt11},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.is_error:
|
||||||
|
payment.extra["lnurl_response"] = r.text
|
||||||
|
else:
|
||||||
|
resp = json.loads(r.text)
|
||||||
|
if resp["status"] != "OK":
|
||||||
|
payment.extra["lnurl_response"] = resp["reason"]
|
||||||
|
else:
|
||||||
|
payment.extra["lnurl_response"] = True
|
||||||
|
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||||
|
logger.error(ex)
|
||||||
|
payment.extra["lnurl_response"] = False
|
||||||
|
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
@@ -132,29 +260,29 @@ async def create_invoice(
|
|||||||
status="failed",
|
status="failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
(
|
payment_response = await funding_source.create_invoice(
|
||||||
ok,
|
|
||||||
checking_id,
|
|
||||||
payment_request,
|
|
||||||
error_message,
|
|
||||||
) = await funding_source.create_invoice(
|
|
||||||
amount=amount_sat,
|
amount=amount_sat,
|
||||||
memo=invoice_memo,
|
memo=invoice_memo,
|
||||||
description_hash=description_hash,
|
description_hash=description_hash,
|
||||||
unhashed_description=unhashed_description,
|
unhashed_description=unhashed_description,
|
||||||
expiry=expiry or settings.lightning_invoice_expiry,
|
expiry=expiry or settings.lightning_invoice_expiry,
|
||||||
)
|
)
|
||||||
if not ok or not payment_request or not checking_id:
|
if (
|
||||||
|
not payment_response.ok
|
||||||
|
or not payment_response.payment_request
|
||||||
|
or not payment_response.checking_id
|
||||||
|
):
|
||||||
raise InvoiceError(
|
raise InvoiceError(
|
||||||
error_message or "unexpected backend error.", status="pending"
|
message=payment_response.error_message or "unexpected backend error.",
|
||||||
|
status="pending",
|
||||||
)
|
)
|
||||||
|
invoice = bolt11_decode(payment_response.payment_request)
|
||||||
invoice = bolt11_decode(payment_request)
|
|
||||||
|
|
||||||
create_payment_model = CreatePayment(
|
create_payment_model = CreatePayment(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
bolt11=payment_request,
|
bolt11=payment_response.payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
|
preimage=payment_response.preimage,
|
||||||
amount_msat=amount_sat * 1000,
|
amount_msat=amount_sat * 1000,
|
||||||
expiry=invoice.expiry_date,
|
expiry=invoice.expiry_date,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
@@ -163,7 +291,7 @@ async def create_invoice(
|
|||||||
)
|
)
|
||||||
|
|
||||||
payment = await create_payment(
|
payment = await create_payment(
|
||||||
checking_id=checking_id,
|
checking_id=payment_response.checking_id,
|
||||||
data=create_payment_model,
|
data=create_payment_model,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
@@ -199,6 +327,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||||
|
amount_msat = abs(amount_msat)
|
||||||
return settings.fee_reserve(amount_msat, internal)
|
return settings.fee_reserve(amount_msat, internal)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +347,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
|
||||||
|
"""
|
||||||
|
Calculate the service fee for a fiat provider based on the amount in msat.
|
||||||
|
Return the fee in msat.
|
||||||
|
"""
|
||||||
|
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||||
|
if not limits:
|
||||||
|
return 0
|
||||||
|
amount_msat = abs(amount_msat)
|
||||||
|
fee_max = limits.service_max_fee_sats * 1000
|
||||||
|
if not limits.service_fee_wallet_id:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
|
||||||
|
if fee_max > 0 and fee_percentage > fee_max:
|
||||||
|
return fee_max
|
||||||
|
else:
|
||||||
|
return fee_percentage
|
||||||
|
|
||||||
|
|
||||||
async def update_wallet_balance(
|
async def update_wallet_balance(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
amount: int,
|
amount: int,
|
||||||
@@ -276,10 +425,7 @@ async def update_wallet_balance(
|
|||||||
)
|
)
|
||||||
payment.status = PaymentState.SUCCESS
|
payment.status = PaymentState.SUCCESS
|
||||||
await update_payment(payment, conn=conn)
|
await update_payment(payment, conn=conn)
|
||||||
# notify receiver asynchronously
|
await internal_invoice_queue_put(payment.checking_id)
|
||||||
from lnbits.tasks import internal_invoice_queue
|
|
||||||
|
|
||||||
await internal_invoice_queue.put(payment.checking_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_wallet_limits(
|
async def check_wallet_limits(
|
||||||
@@ -395,8 +541,9 @@ async def check_transaction_status(
|
|||||||
|
|
||||||
async def get_payments_daily_stats(
|
async def get_payments_daily_stats(
|
||||||
filters: Filters[PaymentFilters],
|
filters: Filters[PaymentFilters],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
) -> list[PaymentDailyStats]:
|
) -> list[PaymentDailyStats]:
|
||||||
data_in, data_out = await get_daily_stats(filters)
|
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
||||||
balance_total: float = 0
|
balance_total: float = 0
|
||||||
|
|
||||||
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
||||||
@@ -409,13 +556,16 @@ async def get_payments_daily_stats(
|
|||||||
|
|
||||||
data: list[PaymentDailyStats] = []
|
data: list[PaymentDailyStats] = []
|
||||||
|
|
||||||
start_date = min(data_in[0].date, data_out[0].date)
|
def _tz(dt: datetime) -> datetime:
|
||||||
end_date = max(data_in[-1].date, data_out[-1].date)
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
|
||||||
|
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
|
||||||
delta = timedelta(days=1)
|
delta = timedelta(days=1)
|
||||||
while start_date <= end_date:
|
while start_date <= end_date:
|
||||||
|
|
||||||
data_in_point = next((x for x in data_in if x.date == start_date), _none)
|
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
|
||||||
data_out_point = next((x for x in data_out if x.date == start_date), _none)
|
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none)
|
||||||
|
|
||||||
balance_total += data_in_point.balance + data_out_point.balance
|
balance_total += data_in_point.balance + data_out_point.balance
|
||||||
data.append(
|
data.append(
|
||||||
@@ -437,11 +587,27 @@ async def get_payments_daily_stats(
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def _pay_invoice(wallet, create_payment_model, conn):
|
async def _pay_invoice(
|
||||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
wallet_id: str,
|
||||||
if not payment:
|
create_payment_model: CreatePayment,
|
||||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
conn: Optional[Connection] = None,
|
||||||
return payment
|
):
|
||||||
|
async with payment_lock:
|
||||||
|
if wallet_id not in wallets_payments_lock:
|
||||||
|
wallets_payments_lock[wallet_id] = asyncio.Lock()
|
||||||
|
|
||||||
|
async with wallets_payments_lock[wallet_id]:
|
||||||
|
# get the wallet again to make sure we have the latest balance
|
||||||
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
|
if not wallet:
|
||||||
|
raise PaymentError(
|
||||||
|
f"Could not fetch wallet '{wallet_id}'.", status="failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||||
|
if not payment:
|
||||||
|
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
async def _pay_internal_invoice(
|
async def _pay_internal_invoice(
|
||||||
@@ -476,12 +642,15 @@ async def _pay_internal_invoice(
|
|||||||
):
|
):
|
||||||
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
||||||
|
|
||||||
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
|
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
|
||||||
create_payment_model.fee = abs(fee_reserve_total_msat)
|
create_payment_model.fee = abs(fee_reserve_total_msat)
|
||||||
|
|
||||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||||
raise PaymentError("Insufficient balance.", status="failed")
|
raise PaymentError("Insufficient balance.", status="failed")
|
||||||
|
|
||||||
|
# release the preimage
|
||||||
|
create_payment_model.preimage = internal_invoice.preimage
|
||||||
|
|
||||||
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(
|
||||||
@@ -519,6 +688,8 @@ async def _pay_external_invoice(
|
|||||||
|
|
||||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||||
|
|
||||||
|
if wallet.balance_msat < abs(amount_msat):
|
||||||
|
raise PaymentError("Insufficient balance.", status="failed")
|
||||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||||
raise PaymentError(
|
raise PaymentError(
|
||||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||||
@@ -553,41 +724,28 @@ async def _pay_external_invoice(
|
|||||||
logger.debug(f"payment timeout, {checking_id} is still pending")
|
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
# payment failed
|
||||||
logger.warning(
|
if (
|
||||||
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
payment_response.checking_id is None
|
||||||
f" {payment_response.checking_id})"
|
or payment_response.ok is False
|
||||||
)
|
or payment_response.checking_id != checking_id
|
||||||
if payment_response.checking_id and payment_response.ok is not False:
|
):
|
||||||
# payment.ok can be True (paid) or None (pending)!
|
|
||||||
logger.debug(f"updating payment {checking_id}")
|
|
||||||
payment.status = (
|
|
||||||
PaymentState.SUCCESS
|
|
||||||
if payment_response.ok is True
|
|
||||||
else PaymentState.PENDING
|
|
||||||
)
|
|
||||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
|
||||||
payment.preimage = payment_response.preimage
|
|
||||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
|
||||||
payment.checking_id = payment_response.checking_id
|
|
||||||
if payment.success:
|
|
||||||
await send_payment_notification(wallet, payment)
|
|
||||||
logger.success(f"payment successful {payment_response.checking_id}")
|
|
||||||
elif payment_response.checking_id is None and payment_response.ok is False:
|
|
||||||
# payment failed
|
|
||||||
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
|
|
||||||
payment.status = PaymentState.FAILED
|
payment.status = PaymentState.FAILED
|
||||||
await update_payment(payment, conn=conn)
|
await update_payment(payment, conn=conn)
|
||||||
raise PaymentError(
|
message = payment_response.error_message or "without an error message."
|
||||||
f"Payment failed: {payment_response.error_message}"
|
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||||
or "Payment failed, but backend didn't give us an error message.",
|
|
||||||
status="failed",
|
# payment.ok can be True (paid) or None (pending)!
|
||||||
)
|
payment.status = (
|
||||||
else:
|
PaymentState.SUCCESS if payment_response.ok is True else PaymentState.PENDING
|
||||||
logger.warning(
|
)
|
||||||
"didn't receive checking_id from backend, payment may be stuck in"
|
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||||
f" database: {checking_id}"
|
payment.preimage = payment_response.preimage
|
||||||
)
|
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||||
|
payment.checking_id = payment_response.checking_id
|
||||||
|
if payment.success:
|
||||||
|
await send_payment_notification(wallet, payment)
|
||||||
|
logger.success(f"payment successful {payment_response.checking_id}")
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
@@ -676,7 +834,7 @@ def _validate_payment_request(
|
|||||||
|
|
||||||
|
|
||||||
async def _credit_service_fee_wallet(
|
async def _credit_service_fee_wallet(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, memo: str, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||||
@@ -687,7 +845,7 @@ async def _credit_service_fee_wallet(
|
|||||||
bolt11=payment.bolt11,
|
bolt11=payment.bolt11,
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
amount_msat=abs(service_fee_msat),
|
amount_msat=abs(service_fee_msat),
|
||||||
memo="Service fee",
|
memo=memo,
|
||||||
)
|
)
|
||||||
await create_payment(
|
await create_payment(
|
||||||
checking_id=f"service_fee_{payment.payment_hash}",
|
checking_id=f"service_fee_{payment.payment_hash}",
|
||||||
@@ -695,3 +853,47 @@ async def _credit_service_fee_wallet(
|
|||||||
status=PaymentState.SUCCESS,
|
status=PaymentState.SUCCESS,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_fiat_invoice_limits(
|
||||||
|
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
|
||||||
|
):
|
||||||
|
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||||
|
if not limits:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
min_amount_sat = limits.service_min_amount_sats
|
||||||
|
if min_amount_sat and (amount_sat < min_amount_sat):
|
||||||
|
raise ValueError(
|
||||||
|
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
|
||||||
|
)
|
||||||
|
max_amount_sats = limits.service_max_amount_sats
|
||||||
|
if max_amount_sats and (amount_sat > max_amount_sats):
|
||||||
|
raise ValueError(
|
||||||
|
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
|
||||||
|
if not limits.service_fee_wallet_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
|
||||||
|
)
|
||||||
|
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
|
||||||
|
if not fees_wallet:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if limits.service_faucet_wallet_id:
|
||||||
|
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
||||||
|
if not faucet_wallet:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
|
||||||
|
)
|
||||||
|
if faucet_wallet.balance < amount_sat:
|
||||||
|
raise ValueError(
|
||||||
|
f"The amount exceeds the '{fiat_provider_name}'"
|
||||||
|
"faucet wallet balance.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Callable, Coroutine
|
from collections.abc import Coroutine
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -132,8 +132,8 @@
|
|||||||
>
|
>
|
||||||
<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">
|
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
<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"
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
<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"
|
||||||
@@ -159,6 +159,22 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_custom_org"
|
||||||
|
:label="$t('auth_keycloak_custom_org_label')"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_custom_icon"
|
||||||
|
:label="$t('auth_keycloak_custom_icon_label')"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||||
></q-tab>
|
></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
|
<q-tab
|
||||||
name="users"
|
name="users"
|
||||||
icon="group"
|
icon="group"
|
||||||
@@ -171,23 +178,27 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
|
|
||||||
<template v-slot:after>
|
<template v-slot:after>
|
||||||
<q-form name="settings_form" id="settings_form">
|
<q-form name="settings_form" id="settings_form">
|
||||||
<q-tab-panels
|
<q-scroll-area style="height: 100vh">
|
||||||
v-model="tab"
|
<q-tab-panels
|
||||||
animated
|
v-model="tab"
|
||||||
swipeable
|
animated
|
||||||
vertical
|
swipeable
|
||||||
transition-prev="jump-up"
|
vertical
|
||||||
transition-next="jump-up"
|
scroll
|
||||||
>
|
transition-prev="jump-up"
|
||||||
{% include "admin/_tab_funding.html" %} {% include
|
transition-next="jump-up"
|
||||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
>
|
||||||
{% include "admin/_tab_exchange_providers.html" %} {% include
|
{% include "admin/_tab_funding.html" %} {% include
|
||||||
"admin/_tab_extensions.html" %} {% include
|
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||||
"admin/_tab_notifications.html" %} {% include
|
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
||||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
"admin/_tab_fiat_providers.html" %} {% include
|
||||||
%}{% include "admin/_tab_audit.html"%}{% include
|
"admin/_tab_extensions.html" %} {% include
|
||||||
"admin/_tab_library.html"%}
|
"admin/_tab_notifications.html" %} {% include
|
||||||
</q-tab-panels>
|
"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>
|
</q-form>
|
||||||
</template>
|
</template>
|
||||||
</q-splitter>
|
</q-splitter>
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
class="cursor-pointer q-ml-sm"
|
class="cursor-pointer q-ml-sm"
|
||||||
@click="copyText(wallet.adminkey)"
|
@click="copyText(wallet.adminkey)"
|
||||||
></q-icon>
|
></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"
|
||||||
|
:options="{ width: 250 }"
|
||||||
|
class="rounded-borders"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</div>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-icon>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
@@ -70,6 +81,17 @@
|
|||||||
class="cursor-pointer q-ml-sm"
|
class="cursor-pointer q-ml-sm"
|
||||||
@click="copyText(wallet.inkey)"
|
@click="copyText(wallet.inkey)"
|
||||||
></q-icon>
|
></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"
|
||||||
|
:options="{ width: 250 }"
|
||||||
|
class="rounded-borders"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</div>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-icon>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|||||||
@@ -272,10 +272,21 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.external_id"
|
||||||
|
:label="$t('external_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.picture"
|
v-model="user.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
filled
|
filled
|
||||||
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
@@ -335,6 +346,22 @@
|
|||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<span v-text="$t('visible_wallet_count')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
v-model="user.extra.visible_wallet_count"
|
||||||
|
:label="$t('visible_wallet_count')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
class="q-mb-md"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row q-mb-md">
|
<div class="row q-mb-md">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<span v-text="$t('color_scheme')"></span>
|
<span v-text="$t('color_scheme')"></span>
|
||||||
@@ -532,6 +559,12 @@
|
|||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="row q-mb-md q-mt-md">
|
||||||
|
<q-btn @click="updateAccount" unelevated color="primary">
|
||||||
|
<span v-text="$t('update_account')"></span>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="api_acls">
|
<q-tab-panel name="api_acls">
|
||||||
<div class="row q-mb-md">
|
<div class="row q-mb-md">
|
||||||
|
|||||||
@@ -59,9 +59,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
|
||||||
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
|
|
||||||
</script>
|
|
||||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||||
{% endblock %} {% block page_container %}
|
{% endblock %} {% block page_container %}
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
@@ -151,7 +148,7 @@
|
|||||||
<username-password
|
<username-password
|
||||||
v-if="authMethod != 'user-id-only'"
|
v-if="authMethod != 'user-id-only'"
|
||||||
:allowed_new_users="allowedRegister"
|
:allowed_new_users="allowedRegister"
|
||||||
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
|
:auth-methods="LNBITS_AUTH_METHODS"
|
||||||
:auth-action="authAction"
|
:auth-action="authAction"
|
||||||
v-model:user-name="username"
|
v-model:user-name="username"
|
||||||
v-model:password_1="password"
|
v-model:password_1="password"
|
||||||
@@ -169,11 +166,12 @@
|
|||||||
v-if="authAction === 'login' && allowedRegister"
|
v-if="authAction === 'login' && allowedRegister"
|
||||||
class="q-mb-none"
|
class="q-mb-none"
|
||||||
>
|
>
|
||||||
Not registered? Create an
|
Not registered?
|
||||||
<span
|
<a
|
||||||
|
href="#"
|
||||||
class="text-secondary cursor-pointer"
|
class="text-secondary cursor-pointer"
|
||||||
@click="showRegister('username-password')"
|
@click.prevent="showRegister('username-password')"
|
||||||
>Account</span
|
>Create an Account</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
@@ -182,15 +180,15 @@
|
|||||||
>
|
>
|
||||||
<span v-text="$t('new_user_not_allowed')"></span>
|
<span v-text="$t('new_user_not_allowed')"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||||
<span v-text="$t('existing_account_question')"></span>
|
<span v-text="$t('existing_account_question')"></span>
|
||||||
|
|
||||||
<span
|
<a
|
||||||
class="text-secondary cursor-pointer"
|
href="#"
|
||||||
@click="showLogin('username-password')"
|
class="text-secondary cursor-pointer q-ml-sm"
|
||||||
|
@click.prevent="showLogin('username-password')"
|
||||||
v-text="$t('login')"
|
v-text="$t('login')"
|
||||||
></span>
|
></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</username-password>
|
</username-password>
|
||||||
@@ -252,7 +250,7 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if AD_SPACE %}
|
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||||
<div class="q-pt-md full-width">
|
<div class="q-pt-md full-width">
|
||||||
<div class="row justify-center q-mb-xl">
|
<div class="row justify-center q-mb-xl">
|
||||||
<div class="full-width text-center">
|
<div class="full-width text-center">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
||||||
%}
|
%}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
{% if HIDE_API and AD_SPACE %}
|
{% if HIDE_API and AD_SPACE_ENABLED and AD_SPACE %}
|
||||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
{% elif HIDE_API %}
|
{% elif HIDE_API %}
|
||||||
<div class="col-12 q-gutter-y-md">
|
<div class="col-12 q-gutter-y-md">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<q-card-section style="height: 130px">
|
<q-card-section style="height: 130px">
|
||||||
<div class="row q-gutter-md">
|
<div class="row q-gutter-md">
|
||||||
<div
|
<div
|
||||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
v-if="isSatsDenomination"
|
||||||
class="col-1"
|
class="col-1"
|
||||||
style="max-width: 30px"
|
style="max-width: 30px"
|
||||||
>
|
>
|
||||||
@@ -211,7 +211,12 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
<div id="hiddenQrCodeContainer" style="display: none">
|
<div id="hiddenQrCodeContainer" style="display: none">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="'lightning:' + this.receive.paymentReq"
|
v-if="receive.fiatPaymentReq"
|
||||||
|
:value="receive.fiatPaymentReq"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
<lnbits-qrcode
|
||||||
|
v-else
|
||||||
|
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +247,23 @@
|
|||||||
{{ SITE_TITLE }} Wallet:
|
{{ SITE_TITLE }} Wallet:
|
||||||
<strong><em v-text="g.wallet.name"></em></strong>
|
<strong><em v-text="g.wallet.name"></em></strong>
|
||||||
</div>
|
</div>
|
||||||
|
<q-space></q-space>
|
||||||
|
<div class="float-right">
|
||||||
|
<q-btn
|
||||||
|
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
|
||||||
|
round
|
||||||
|
class="float-right"
|
||||||
|
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
|
||||||
|
text-color="black"
|
||||||
|
size="sm"
|
||||||
|
icon="push_pin"
|
||||||
|
style="transform: rotate(30deg)"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
><span v-text="$t('pin_wallet')"></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
@@ -468,7 +490,7 @@
|
|||||||
</q-list>
|
</q-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endif %} {% if AD_SPACE %}
|
{% endif %} {% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
@@ -602,14 +624,30 @@
|
|||||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
></q-input>
|
></q-input>
|
||||||
{% else %}
|
{% else %}
|
||||||
<q-select
|
<div class="row">
|
||||||
filled
|
<div class="col-10">
|
||||||
dense
|
<q-select
|
||||||
v-model="receive.unit"
|
filled
|
||||||
type="text"
|
dense
|
||||||
:label="$t('unit')"
|
v-model="receive.unit"
|
||||||
:options="receive.units"
|
type="text"
|
||||||
></q-select>
|
:label="$t('unit')"
|
||||||
|
:options="receive.units"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<q-btn
|
||||||
|
v-if="g.fiatTracking"
|
||||||
|
@click="swapBalancePriority"
|
||||||
|
class="float-right"
|
||||||
|
color="primary"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="swap_vert"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
ref="setAmount"
|
ref="setAmount"
|
||||||
filled
|
filled
|
||||||
@@ -627,9 +665,59 @@
|
|||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
v-model.trim="receive.data.memo"
|
v-model.trim="receive.data.memo"
|
||||||
:label="$t('memo')"
|
:label="$t('memo')"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
|
||||||
|
<q-list bordered dense class="rounded-borders">
|
||||||
|
<q-item-label dense header>
|
||||||
|
<span v-text="$t('select_payment_provider')"></span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-item
|
||||||
|
:active="!receive.fiatProvider"
|
||||||
|
@click="receive.fiatProvider = ''"
|
||||||
|
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar square>
|
||||||
|
<img
|
||||||
|
:src="'{{ static_url_for('static', 'images/logos/lnbits.png') }}'"
|
||||||
|
/>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span
|
||||||
|
v-text="$t('pay_with', {provider: 'Lightning Network'})"
|
||||||
|
></span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-item
|
||||||
|
:active="receive.fiatProvider === 'stripe'"
|
||||||
|
@click="receive.fiatProvider = 'stripe'"
|
||||||
|
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar>
|
||||||
|
<img
|
||||||
|
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
||||||
|
/>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@@ -639,7 +727,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="receive.lnurl"
|
v-if="receive.lnurl"
|
||||||
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
|
||||||
></span>
|
></span>
|
||||||
<span v-else v-text="$t('create_invoice')"></span>
|
<span v-else v-text="$t('create_invoice')"></span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
@@ -663,7 +751,14 @@
|
|||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
>
|
>
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<a :href="'lightning:' + receive.paymentReq">
|
<a
|
||||||
|
v-if="receive.fiatPaymentReq"
|
||||||
|
:href="receive.fiatPaymentReq"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<div v-html="invoiceQrCode"></div>
|
||||||
|
</a>
|
||||||
|
<a v-else :href="'lightning:' + receive.paymentReq">
|
||||||
<div v-html="invoiceQrCode"></div>
|
<div v-html="invoiceQrCode"></div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -674,25 +769,27 @@
|
|||||||
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
||||||
<span v-text="formattedSatAmount"></span>
|
<span v-text="formattedSatAmount"></span>
|
||||||
</h5>
|
</h5>
|
||||||
<q-chip v-if="hasNfc" outline square color="positive">
|
<div v-if="!receive.fiatPaymentReq">
|
||||||
<q-avatar
|
<q-chip v-if="hasNfc" outline square color="positive">
|
||||||
icon="nfc"
|
<q-avatar
|
||||||
color="positive"
|
icon="nfc"
|
||||||
text-color="white"
|
color="positive"
|
||||||
></q-avatar>
|
text-color="white"
|
||||||
<span v-text="$t('nfc_supported')"></span>
|
></q-avatar>
|
||||||
</q-chip>
|
<span v-text="$t('nfc_supported')"></span>
|
||||||
<span
|
</q-chip>
|
||||||
v-else
|
<span
|
||||||
class="text-caption text-grey"
|
v-else
|
||||||
v-text="$t('nfc_not_supported')"
|
class="text-caption text-grey"
|
||||||
></span>
|
v-text="$t('nfc_not_supported')"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="copyText(receive.paymentReq)"
|
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
|
||||||
:label="$t('copy_invoice')"
|
:label="$t('copy_invoice')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -961,8 +1058,8 @@
|
|||||||
dense
|
dense
|
||||||
v-model.number="parse.data.amount"
|
v-model.number="parse.data.amount"
|
||||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||||
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
:mask="parse.data.unit == 'sat' ? '#' : ''"
|
||||||
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
:step="parse.data.unit == 'sat' ? '1': '0.01'"
|
||||||
fill-mask="0"
|
fill-mask="0"
|
||||||
reverse-fill-mask
|
reverse-fill-mask
|
||||||
:min="parse.lnurlpay.minSendable / 1000"
|
:min="parse.lnurlpay.minSendable / 1000"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -232,15 +232,57 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-select
|
<q-btn
|
||||||
v-else-if="['status'].includes(col.name)"
|
v-else-if="['status'].includes(col.name)"
|
||||||
v-model="searchData[col.name]"
|
flat
|
||||||
:options="searchOptions[col.name]"
|
dense
|
||||||
@update:model-value="searchPaymentsBy()"
|
:label="$q.screen.gt.md ? 'Status' : null"
|
||||||
:label="col.label"
|
icon="filter_alt"
|
||||||
clearable
|
color="grey"
|
||||||
style="width: 100px"
|
class="text-capitalize"
|
||||||
></q-select>
|
>
|
||||||
|
<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
|
<q-select
|
||||||
v-else-if="['tag'].includes(col.name)"
|
v-else-if="['tag'].includes(col.name)"
|
||||||
v-model="searchData[col.name]"
|
v-model="searchData[col.name]"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
:label="$t('set_password')"
|
:label="$t('set_password')"
|
||||||
v-model="activeUser.setPassword"
|
v-model="activeUser.setPassword"
|
||||||
>
|
>
|
||||||
<q-tooltip>Toggle Admin</q-tooltip>
|
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
|
||||||
</q-toggle>
|
</q-toggle>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
@@ -105,11 +105,12 @@
|
|||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.pubkey"
|
v-model="activeUser.data.pubkey"
|
||||||
:label="$t('pubkey')"
|
:label="'Nostr '+ $t('pubkey')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
|
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.email"
|
v-model="activeUser.data.email"
|
||||||
@@ -146,6 +147,14 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="activeUser.data.external_id"
|
||||||
|
:label="$t('external_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.extra.picture"
|
v-model="activeUser.data.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
|
|||||||
@@ -15,19 +15,36 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="activeWallet.show">
|
<div v-else-if="activeWallet.show">
|
||||||
<div class="row q-mb-lg">
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
<div class="col">
|
<div class="col-12">
|
||||||
<q-btn
|
<q-card>
|
||||||
icon="arrow_back_ios"
|
<div class="q-pa-sm">
|
||||||
@click="backToUsersPage()"
|
<div class="row">
|
||||||
:label="$t('back')"
|
<div class="q-pa-xs">
|
||||||
></q-btn>
|
<q-btn
|
||||||
<q-btn
|
icon="arrow_back_ios"
|
||||||
@click="createWalletDialog.show = true"
|
@click="backToUsersPage()"
|
||||||
:label="$t('create_new_wallet')"
|
:label="$t('back')"
|
||||||
color="primary"
|
></q-btn>
|
||||||
class="q-ml-md"
|
</div>
|
||||||
></q-btn>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<q-card class="q-pa-md">
|
<q-card class="q-pa-md">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
|||||||
from lnbits.tasks import invoice_listeners
|
from lnbits.tasks import invoice_listeners
|
||||||
|
|
||||||
from .. import core_app_extra
|
from .. import core_app_extra
|
||||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||||
|
|
||||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||||
file_upload = File(...)
|
file_upload = File(...)
|
||||||
@@ -113,7 +113,7 @@ 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(user: User = Depends(check_super_user)) -> None:
|
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||||
await delete_admin_settings()
|
await reset_core_settings()
|
||||||
server_restart.set()
|
server_restart.set()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,14 +130,20 @@ async def api_lnurlscan(
|
|||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||||
check_callback_url(url)
|
check_callback_url(url)
|
||||||
r = await client.get(url, timeout=5)
|
try:
|
||||||
r.raise_for_status()
|
r = await client.get(url, timeout=5)
|
||||||
if r.is_error:
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
detail={"domain": domain, "message": "failed to get parameters"},
|
detail={
|
||||||
)
|
"domain": domain,
|
||||||
|
"message": "failed to get parameters",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
try:
|
try:
|
||||||
data = json.loads(r.text)
|
data = json.loads(r.text)
|
||||||
except json.decoder.JSONDecodeError as exc:
|
except json.decoder.JSONDecodeError as exc:
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
|||||||
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):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed."
|
||||||
)
|
)
|
||||||
account = await get_account_by_username_or_email(data.username)
|
account = await get_account_by_username_or_email(data.username)
|
||||||
if not account or not account.verify_password(data.password):
|
if not account or not account.verify_password(data.password):
|
||||||
@@ -85,9 +85,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
|||||||
@auth_router.post("/nostr", description="Login via Nostr")
|
@auth_router.post("/nostr", description="Login via Nostr")
|
||||||
async def nostr_login(request: Request) -> JSONResponse:
|
async def nostr_login(request: Request) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||||
raise HTTPException(
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||||
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
|
|
||||||
)
|
|
||||||
event = _nostr_nip98_event(request)
|
event = _nostr_nip98_event(request)
|
||||||
account = await get_account_by_pubkey(event["pubkey"])
|
account = await get_account_by_pubkey(event["pubkey"])
|
||||||
if not account:
|
if not account:
|
||||||
@@ -104,7 +102,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
|||||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.FORBIDDEN,
|
||||||
"Login by 'User ID' not allowed.",
|
"Login by 'User ID' not allowed.",
|
||||||
)
|
)
|
||||||
account = await get_account(data.usr)
|
account = await get_account(data.usr)
|
||||||
@@ -112,7 +110,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
|||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||||
if account.is_admin:
|
if account.is_admin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
|
HTTPStatus.FORBIDDEN, "Admin users cannot login with user id only."
|
||||||
)
|
)
|
||||||
return _auth_success_response(account.username, account.id, account.email)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
@@ -242,7 +240,7 @@ async def login_with_sso_provider(
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.FORBIDDEN,
|
||||||
f"Login by '{provider}' not allowed.",
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,7 +255,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.FORBIDDEN,
|
||||||
f"Login by '{provider}' not allowed.",
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,7 +283,7 @@ async def logout() -> JSONResponse:
|
|||||||
async def register(data: RegisterUser) -> JSONResponse:
|
async def register(data: RegisterUser) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.FORBIDDEN,
|
||||||
"Register by 'Username and Password' not allowed.",
|
"Register by 'Username and Password' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,7 +373,7 @@ async def update_password(
|
|||||||
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
|
||||||
)
|
)
|
||||||
|
|
||||||
assert data.password == data.password_repeat, "Passwords do not match."
|
assert data.password == data.password_repeat, "Passwords do not match."
|
||||||
@@ -413,23 +411,13 @@ async def update(
|
|||||||
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):
|
if data.username and not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||||
if data.email != user.email:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST,
|
|
||||||
"Email mismatch.",
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
data.username
|
data.username
|
||||||
and user.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.")
|
||||||
if (
|
|
||||||
data.email
|
|
||||||
and data.email != user.email
|
|
||||||
and await get_account_by_email(data.email)
|
|
||||||
):
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
|
|
||||||
|
|
||||||
account = await get_account(user.id)
|
account = await get_account(user.id)
|
||||||
if not account:
|
if not account:
|
||||||
@@ -437,8 +425,6 @@ async def update(
|
|||||||
|
|
||||||
if data.username:
|
if data.username:
|
||||||
account.username = data.username
|
account.username = data.username
|
||||||
if data.email:
|
|
||||||
account.email = data.email
|
|
||||||
if data.extra:
|
if data.extra:
|
||||||
account.extra = data.extra
|
account.extra = data.extra
|
||||||
|
|
||||||
@@ -449,7 +435,7 @@ async def update(
|
|||||||
@auth_router.put("/first_install")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
|
||||||
account = await get_account(settings.super_user)
|
account = await get_account(settings.super_user)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||||
@@ -472,10 +458,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
|
|||||||
|
|
||||||
if verified_user_id:
|
if verified_user_id:
|
||||||
if account:
|
if account:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
|
||||||
account = await get_account(verified_user_id)
|
account = await get_account(verified_user_id)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
|
||||||
redirect_path = "/account"
|
redirect_path = "/account"
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
@@ -499,9 +485,12 @@ def _auth_success_response(
|
|||||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||||
)
|
)
|
||||||
access_token = create_access_token(data=payload.dict())
|
access_token = create_access_token(data=payload.dict())
|
||||||
|
max_age = settings.auth_token_expire_minutes * 60
|
||||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie(
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||||
|
)
|
||||||
|
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -521,9 +510,12 @@ def _auth_api_token_response(
|
|||||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||||
access_token = create_access_token(data=payload.dict())
|
access_token = create_access_token(data=payload.dict())
|
||||||
|
max_age = settings.auth_token_expire_minutes * 60
|
||||||
response = RedirectResponse(path)
|
response = RedirectResponse(path)
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie(
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||||
|
)
|
||||||
|
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -574,10 +566,10 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
|||||||
def _nostr_nip98_event(request: Request) -> dict:
|
def _nostr_nip98_event(request: Request) -> dict:
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr Auth header missing.")
|
||||||
scheme, token = auth_header.split()
|
scheme, token = auth_header.split()
|
||||||
if scheme.lower() != "nostr":
|
if scheme.lower() != "nostr":
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
|
||||||
event = None
|
event = None
|
||||||
try:
|
try:
|
||||||
event_json = base64.b64decode(token.encode("ascii"))
|
event_json = base64.b64decode(token.encode("ascii"))
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
|
from lnbits.core.services.fiat_providers import (
|
||||||
|
check_stripe_signature,
|
||||||
|
handle_stripe_event,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
||||||
|
|
||||||
|
|
||||||
|
@callback_router.post("/{provider_name}")
|
||||||
|
async def api_generic_webhook_handler(
|
||||||
|
provider_name: str, request: Request
|
||||||
|
) -> SimpleStatus:
|
||||||
|
|
||||||
|
if provider_name.lower() == "stripe":
|
||||||
|
payload = await request.body()
|
||||||
|
sig_header = request.headers.get("Stripe-Signature")
|
||||||
|
check_stripe_signature(
|
||||||
|
payload, sig_header, settings.stripe_webhook_signing_secret
|
||||||
|
)
|
||||||
|
event = await request.json()
|
||||||
|
await handle_stripe_event(event)
|
||||||
|
|
||||||
|
return SimpleStatus(
|
||||||
|
success=True,
|
||||||
|
message=f"Callback received successfully from '{provider_name}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return SimpleStatus(
|
||||||
|
success=False,
|
||||||
|
message=f"Unknown fiat provider '{provider_name}'.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
|
from lnbits.core.services.fiat_providers import test_connection
|
||||||
|
from lnbits.decorators import check_admin
|
||||||
|
|
||||||
|
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||||
|
|
||||||
|
|
||||||
|
@fiat_router.put(
|
||||||
|
"/check/{provider}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||||
|
return await test_connection(provider)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated, List, Optional, Union
|
from typing import Annotated, Optional, Union
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -70,7 +70,7 @@ async def robots():
|
|||||||
|
|
||||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
installed_exts: list[InstallableExtension] = await get_installed_extensions()
|
||||||
installed_exts_ids = [e.id for e in installed_exts]
|
installed_exts_ids = [e.id for e in installed_exts]
|
||||||
|
|
||||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||||
@@ -216,6 +216,25 @@ async def account(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
@@ -402,7 +421,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
|
|
||||||
|
|
||||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||||
async def payments_index(request: Request, user: User = Depends(check_admin)):
|
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"payments/index.html",
|
"payments/index.html",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -91,7 +91,7 @@ async def api_get_info(
|
|||||||
@node_router.get("/channels")
|
@node_router.get("/channels")
|
||||||
async def api_get_channels(
|
async def api_get_channels(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[List[NodeChannel]]:
|
) -> Optional[list[NodeChannel]]:
|
||||||
return await node.get_channels()
|
return await node.get_channels()
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ async def api_delete_channel(
|
|||||||
output_index: Optional[int],
|
output_index: Optional[int],
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> Optional[List[NodeChannel]]:
|
) -> Optional[list[NodeChannel]]:
|
||||||
return await node.close_channel(
|
return await node.close_channel(
|
||||||
short_id,
|
short_id,
|
||||||
(
|
(
|
||||||
@@ -170,7 +170,7 @@ async def api_get_invoices(
|
|||||||
|
|
||||||
|
|
||||||
@node_router.get("/peers")
|
@node_router.get("/peers")
|
||||||
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
async def api_get_peers(node: Node = Depends(require_node)) -> list[NodePeerInfo]:
|
||||||
return await node.get_peers()
|
return await node.get_peers()
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +212,10 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
|
|||||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||||
try:
|
try:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["noderank"]
|
data = r.json()
|
||||||
|
if "noderank" not in data:
|
||||||
|
return None
|
||||||
|
return data["noderank"]
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
import ssl
|
import ssl
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -34,17 +34,11 @@ from lnbits.core.models import (
|
|||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
PaymentWalletStats,
|
PaymentWalletStats,
|
||||||
Wallet,
|
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import User
|
from lnbits.core.models.users import User
|
||||||
from lnbits.core.services.payments import (
|
|
||||||
get_payments_daily_stats,
|
|
||||||
update_pending_payment,
|
|
||||||
)
|
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
check_admin,
|
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
@@ -68,9 +62,12 @@ from ..crud import (
|
|||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
create_invoice,
|
create_fiat_invoice,
|
||||||
|
create_wallet_invoice,
|
||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
|
get_payments_daily_stats,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
|
update_pending_payment,
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +79,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
|||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get list of payments",
|
summary="get list of payments",
|
||||||
response_description="list of payments",
|
response_description="list of payments",
|
||||||
response_model=List[Payment],
|
response_model=list[Payment],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments(
|
async def api_payments(
|
||||||
@@ -101,7 +98,7 @@ async def api_payments(
|
|||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/history",
|
"/history",
|
||||||
name="Get payments history",
|
name="Get payments history",
|
||||||
response_model=List[PaymentHistoryPoint],
|
response_model=list[PaymentHistoryPoint],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_history(
|
async def api_payments_history(
|
||||||
@@ -116,36 +113,50 @@ async def api_payments_history(
|
|||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/count",
|
"/stats/count",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
dependencies=[Depends(check_admin)],
|
response_model=list[PaymentCountStat],
|
||||||
response_model=List[PaymentCountStat],
|
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
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)),
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
|
||||||
return await get_payment_count_stats(count_by, filters)
|
if user.admin:
|
||||||
|
# admin user can see payments from all wallets
|
||||||
|
for_user_id = None
|
||||||
|
else:
|
||||||
|
# regular user can only see payments from their wallets
|
||||||
|
for_user_id = user.id
|
||||||
|
|
||||||
|
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/wallets",
|
"/stats/wallets",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
dependencies=[Depends(check_admin)],
|
response_model=list[PaymentWalletStats],
|
||||||
response_model=List[PaymentWalletStats],
|
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
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)),
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
|
||||||
return await get_wallets_stats(filters)
|
if user.admin:
|
||||||
|
# admin user can see payments from all wallets
|
||||||
|
for_user_id = None
|
||||||
|
else:
|
||||||
|
# regular user can only see payments from their wallets
|
||||||
|
for_user_id = user.id
|
||||||
|
|
||||||
|
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/daily",
|
"/stats/daily",
|
||||||
name="Get payments history per day",
|
name="Get payments history per day",
|
||||||
response_model=List[PaymentDailyStats],
|
response_model=list[PaymentDailyStats],
|
||||||
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(
|
||||||
@@ -153,22 +164,13 @@ async def api_payments_daily_stats(
|
|||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
|
|
||||||
if not user.admin:
|
if user.admin:
|
||||||
exc = HTTPException(
|
# admin user can see payments from all wallets
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
for_user_id = None
|
||||||
detail="Missing wallet id.",
|
else:
|
||||||
)
|
# regular user can only see payments from their wallets
|
||||||
wallet_filter = next(
|
for_user_id = user.id
|
||||||
(f for f in filters.filters if f.field == "wallet_id"), None
|
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
||||||
)
|
|
||||||
if not wallet_filter:
|
|
||||||
raise exc
|
|
||||||
wallet_id = list((wallet_filter.values or {}).values())
|
|
||||||
if len(wallet_id) == 0:
|
|
||||||
raise exc
|
|
||||||
if not user.get_wallet(wallet_id[0]):
|
|
||||||
raise exc
|
|
||||||
return await get_payments_daily_stats(filters)
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
@@ -194,69 +196,6 @@ async def api_payments_paginated(
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|
||||||
description_hash = b""
|
|
||||||
unhashed_description = b""
|
|
||||||
memo = data.memo or settings.lnbits_site_title
|
|
||||||
if data.description_hash or data.unhashed_description:
|
|
||||||
if data.description_hash:
|
|
||||||
try:
|
|
||||||
description_hash = bytes.fromhex(data.description_hash)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="'description_hash' must be a valid hex string",
|
|
||||||
) from exc
|
|
||||||
if data.unhashed_description:
|
|
||||||
try:
|
|
||||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="'unhashed_description' must be a valid hex string",
|
|
||||||
) from exc
|
|
||||||
# do not save memo if description_hash or unhashed_description is set
|
|
||||||
memo = ""
|
|
||||||
|
|
||||||
payment = await create_invoice(
|
|
||||||
wallet_id=wallet.id,
|
|
||||||
amount=data.amount,
|
|
||||||
memo=memo,
|
|
||||||
currency=data.unit,
|
|
||||||
description_hash=description_hash,
|
|
||||||
unhashed_description=unhashed_description,
|
|
||||||
expiry=data.expiry,
|
|
||||||
extra=data.extra,
|
|
||||||
webhook=data.webhook,
|
|
||||||
internal=data.internal,
|
|
||||||
)
|
|
||||||
|
|
||||||
# lnurl_response is not saved in the database
|
|
||||||
if data.lnurl_callback:
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
|
||||||
try:
|
|
||||||
check_callback_url(data.lnurl_callback)
|
|
||||||
r = await client.get(
|
|
||||||
data.lnurl_callback,
|
|
||||||
params={"pr": payment.bolt11},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if r.is_error:
|
|
||||||
payment.extra["lnurl_response"] = r.text
|
|
||||||
else:
|
|
||||||
resp = json.loads(r.text)
|
|
||||||
if resp["status"] != "OK":
|
|
||||||
payment.extra["lnurl_response"] = resp["reason"]
|
|
||||||
else:
|
|
||||||
payment.extra["lnurl_response"] = True
|
|
||||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
|
||||||
logger.error(ex)
|
|
||||||
payment.extra["lnurl_response"] = False
|
|
||||||
|
|
||||||
return payment
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/all/paginated",
|
"/all/paginated",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
@@ -264,13 +203,21 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
response_description="list of payments",
|
response_description="list of payments",
|
||||||
response_model=Page[Payment],
|
response_model=Page[Payment],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
dependencies=[Depends(check_admin)],
|
|
||||||
)
|
)
|
||||||
async def api_all_payments_paginated(
|
async def api_all_payments_paginated(
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
if user.admin:
|
||||||
|
# admin user can see payments from all wallets
|
||||||
|
for_user_id = None
|
||||||
|
else:
|
||||||
|
# regular user can only see payments from their wallets
|
||||||
|
for_user_id = user.id
|
||||||
|
|
||||||
return await get_payments_paginated(
|
return await get_payments_paginated(
|
||||||
filters=filters,
|
filters=filters,
|
||||||
|
user_id=for_user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +243,7 @@ async def api_payments_create(
|
|||||||
invoice_data: CreateInvoice,
|
invoice_data: CreateInvoice,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
|
wallet_id = wallet.wallet.id
|
||||||
if invoice_data.out is True and wallet.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(
|
||||||
@@ -303,21 +251,24 @@ async def api_payments_create(
|
|||||||
detail="Missing BOLT11 invoice",
|
detail="Missing BOLT11 invoice",
|
||||||
)
|
)
|
||||||
payment = await pay_invoice(
|
payment = await pay_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet_id,
|
||||||
payment_request=invoice_data.bolt11,
|
payment_request=invoice_data.bolt11,
|
||||||
extra=invoice_data.extra,
|
extra=invoice_data.extra,
|
||||||
)
|
)
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
elif not invoice_data.out:
|
if invoice_data.out:
|
||||||
# invoice key
|
|
||||||
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
detail="Invoice (or Admin) key required.",
|
detail="Invoice (or Admin) key required.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If the payment is not outgoing, we can create a new invoice.
|
||||||
|
if invoice_data.fiat_provider:
|
||||||
|
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||||
|
|
||||||
|
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -427,6 +378,9 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
|||||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
if payment.failed:
|
||||||
|
return {"paid": False, "status": "failed", "details": payment}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
|||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
|
external_id=data.external_id,
|
||||||
extra=data.extra,
|
extra=data.extra,
|
||||||
)
|
)
|
||||||
account.validate_fields()
|
account.validate_fields()
|
||||||
@@ -132,6 +133,7 @@ async def api_update_user(
|
|||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
|
external_id=data.external_id,
|
||||||
extra=data.extra or UserExtra(),
|
extra=data.extra or UserExtra(),
|
||||||
)
|
)
|
||||||
await update_user_account(account)
|
await update_user_account(account)
|
||||||
@@ -148,7 +150,7 @@ async def api_update_user(
|
|||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, user: User = Depends(check_admin)
|
user_id: str, user: User = Depends(check_admin)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
wallets = await get_wallets(user_id)
|
wallets = await get_wallets(user_id, deleted=False)
|
||||||
if len(wallets) > 0:
|
if len(wallets) > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -214,7 +216,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
|||||||
|
|
||||||
|
|
||||||
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
||||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
|
||||||
return await get_wallets(user_id)
|
return await get_wallets(user_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -257,6 +259,28 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
|
|||||||
return SimpleStatus(success=True, message="Wallet is already active.")
|
return SimpleStatus(success=True, message="Wallet is already active.")
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.delete(
|
||||||
|
"/user/{user_id}/wallets",
|
||||||
|
name="Delete all wallets for user",
|
||||||
|
summary="Soft delete (only sets a flag) all user wallets.",
|
||||||
|
)
|
||||||
|
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
|
||||||
|
if user_id == settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Action not allowed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
wallets = await get_wallets(user_id, deleted=False)
|
||||||
|
for wallet in wallets:
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||||
|
|
||||||
|
return SimpleStatus(
|
||||||
|
success=True,
|
||||||
|
message=f"Deleted '{len(wallets)}' wallets. ",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@users_router.delete(
|
@users_router.delete(
|
||||||
"/user/{user_id}/wallet/{wallet}",
|
"/user/{user_id}/wallet/{wallet}",
|
||||||
name="Delete wallet by id",
|
name="Delete wallet by id",
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ from fastapi import (
|
|||||||
HTTPException,
|
HTTPException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
||||||
|
from lnbits.core.models.wallets import WalletsFilters
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
from lnbits.helpers import generate_filter_params_openapi
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@wallet_router.get(
|
||||||
|
"/paginated",
|
||||||
|
name="Wallet List",
|
||||||
|
summary="get paginated list of user wallets",
|
||||||
|
response_description="list of user wallets",
|
||||||
|
response_model=Page[Wallet],
|
||||||
|
openapi_extra=generate_filter_params_openapi(WalletsFilters),
|
||||||
|
)
|
||||||
|
async def api_wallets_paginated(
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
filters: Filters = Depends(parse_filters(WalletsFilters)),
|
||||||
|
):
|
||||||
|
page = await get_wallets_paginated(
|
||||||
|
user_id=user.id,
|
||||||
|
filters=filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
@@ -74,6 +99,7 @@ async def api_update_wallet(
|
|||||||
icon: Optional[str] = Body(None),
|
icon: Optional[str] = Body(None),
|
||||||
color: Optional[str] = Body(None),
|
color: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
|
pinned: Optional[bool] = Body(None),
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
@@ -82,6 +108,7 @@ async def api_update_wallet(
|
|||||||
wallet.name = name or wallet.name
|
wallet.name = name or wallet.name
|
||||||
wallet.extra.icon = icon or wallet.extra.icon
|
wallet.extra.icon = icon or wallet.extra.icon
|
||||||
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.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
|
||||||
|
|||||||
+20
-7
@@ -554,9 +554,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
where_stmts.append(page_filter.statement)
|
where_stmts.append(page_filter.statement)
|
||||||
if self.search and self.model and self.model.__search_fields__:
|
if self.search and self.model and self.model.__search_fields__:
|
||||||
where_stmts.append(
|
# Use `COALESCE` to handle `NULL` values and `||`
|
||||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
# for cross-database compatible string concatenation
|
||||||
|
_fields = self.model.__search_fields__
|
||||||
|
search_expr = " || ".join(
|
||||||
|
f"COALESCE(CAST({field} AS TEXT), '')" for field in _fields
|
||||||
)
|
)
|
||||||
|
where_stmts.append(f"lower({search_expr}) LIKE :search")
|
||||||
|
|
||||||
if where_stmts:
|
if where_stmts:
|
||||||
return "WHERE " + " AND ".join(where_stmts)
|
return "WHERE " + " AND ".join(where_stmts)
|
||||||
@@ -576,7 +580,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for key, value in page_filter.values.items():
|
for key, value in page_filter.values.items():
|
||||||
values[key] = value
|
values[key] = value
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
values["search"] = f"%{self.search}%"
|
values["search"] = f"%{self.search.lower()}%"
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -644,9 +648,9 @@ def model_to_dict(model: BaseModel) -> dict:
|
|||||||
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||||
"""convert a dictionary or JSON string to a Pydantic model"""
|
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
if value == "null":
|
if value == "null" or value == "":
|
||||||
return None
|
return None
|
||||||
_subdict = json.loads(value)
|
_subdict = _safe_load_json(value)
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
_subdict = value
|
_subdict = value
|
||||||
|
|
||||||
@@ -670,7 +674,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
|||||||
type_ = model.__fields__[key].type_
|
type_ = model.__fields__[key].type_
|
||||||
outertype_ = model.__fields__[key].outer_type_
|
outertype_ = model.__fields__[key].outer_type_
|
||||||
if get_origin(outertype_) is list:
|
if get_origin(outertype_) is list:
|
||||||
_items = json.loads(value) if isinstance(value, str) else value
|
_items = _safe_load_json(value) if isinstance(value, str) else value
|
||||||
_dict[key] = [
|
_dict[key] = [
|
||||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||||
for v in _items
|
for v in _items
|
||||||
@@ -691,7 +695,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
|||||||
# TODO: remove this when all sub models are migrated to Pydantic
|
# TODO: remove this when all sub models are migrated to Pydantic
|
||||||
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
||||||
if type_ is dict and value:
|
if type_ is dict and value:
|
||||||
_dict[key] = json.loads(value)
|
_dict[key] = _safe_load_json(value)
|
||||||
continue
|
continue
|
||||||
_dict[key] = value
|
_dict[key] = value
|
||||||
continue
|
continue
|
||||||
@@ -699,3 +703,12 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
|||||||
if isinstance(_model, BaseModel):
|
if isinstance(_model, BaseModel):
|
||||||
_model.__init__(**_dict) # type: ignore
|
_model.__init__(**_dict) # type: ignore
|
||||||
return _model
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_load_json(value: str) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# DB is corrupted if it gets here
|
||||||
|
logger.error(f"Failed to decode JSON: '{value}'")
|
||||||
|
return {}
|
||||||
|
|||||||
+20
-7
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated, Literal, Optional, Type, Union
|
from typing import Annotated, Literal, Optional, Union
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Cookie, Depends, Query, Request, Security
|
from fastapi import Cookie, Depends, Query, Request, Security
|
||||||
@@ -28,7 +28,7 @@ from lnbits.core.models import (
|
|||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
)
|
)
|
||||||
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||||
from lnbits.helpers import path_segments
|
from lnbits.helpers import normalize_path, path_segments
|
||||||
from lnbits.settings import AuthMethods, settings
|
from lnbits.settings import AuthMethods, settings
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(
|
oauth2_scheme = OAuth2PasswordBearer(
|
||||||
@@ -102,7 +102,7 @@ class KeyChecker(SecurityBase):
|
|||||||
request.scope["user_id"] = wallet.user
|
request.scope["user_id"] = wallet.user
|
||||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
detail="Invalid adminkey.",
|
detail="Invalid adminkey.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ async def check_user_exists(
|
|||||||
|
|
||||||
r.scope["user_id"] = account.id
|
r.scope["user_id"] = account.id
|
||||||
if not settings.is_user_allowed(account.id):
|
if not settings.is_user_allowed(account.id):
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||||
|
|
||||||
user = await get_user_from_account(account)
|
user = await get_user_from_account(account)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -201,7 +201,7 @@ async def access_token_payload(
|
|||||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
|
||||||
)
|
)
|
||||||
if not user.has_password:
|
if not user.has_password:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -214,7 +214,7 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
|
|||||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
if user.id != settings.super_user:
|
if user.id != settings.super_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||||
)
|
)
|
||||||
if not user.has_password:
|
if not user.has_password:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -223,7 +223,7 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def parse_filters(model: Type[TFilterModel]):
|
def parse_filters(model: type[TFilterModel]):
|
||||||
"""
|
"""
|
||||||
Parses the query params as filters.
|
Parses the query params as filters.
|
||||||
:param model: model used for validation of filter values
|
:param model: model used for validation of filter values
|
||||||
@@ -346,3 +346,16 @@ async def _check_account_api_access(
|
|||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
|
||||||
if not endpoint.supports_method(method):
|
if not endpoint.supports_method(method):
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
||||||
|
|
||||||
|
|
||||||
|
def url_for_interceptor(original_method):
|
||||||
|
def normalize_url(self, *args, **kwargs):
|
||||||
|
url = original_method(self, *args, **kwargs)
|
||||||
|
return url.replace(path=normalize_path(url.path))
|
||||||
|
|
||||||
|
return normalize_url
|
||||||
|
|
||||||
|
|
||||||
|
# Upgraded extensions modify the path.
|
||||||
|
# This interceptor ensures that the path is normalized.
|
||||||
|
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from shortuuid import uuid
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ class InvoiceError(Exception):
|
|||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||||
# Only the browser sends "text/html" request
|
# Only the browser sends "text/html" request
|
||||||
# not fail proof, but everything else get's a JSON response
|
# not fail proof, but everything else get's a JSON response
|
||||||
@@ -71,10 +76,11 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
async def exception_handler(request: Request, exc: Exception):
|
async def exception_handler(request: Request, exc: Exception):
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
traceback.print_exception(etype, exc, tb)
|
traceback.print_exception(etype, exc, tb)
|
||||||
logger.error(f"Exception: {exc!s}")
|
exception_id = uuid()
|
||||||
|
logger.error(f"Exception ID: {exception_id}\n{exc!s}")
|
||||||
return render_html_error(request, exc) or JSONResponse(
|
return render_html_error(request, exc) or JSONResponse(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
content={"detail": str(exc)},
|
content={"detail": f"Unexpected error! ID: {exception_id}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(AssertionError)
|
@app.exception_handler(AssertionError)
|
||||||
@@ -145,7 +151,7 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
status_code = HTTPStatus.NOT_FOUND
|
status_code = HTTPStatus.NOT_FOUND
|
||||||
message: str = "Page not found."
|
message: str = "Page not found."
|
||||||
|
|
||||||
if path in settings.lnbits_all_extensions_ids:
|
if settings.is_ready_to_install_extension_id(path):
|
||||||
status_code = HTTPStatus.FORBIDDEN
|
status_code = HTTPStatus.FORBIDDEN
|
||||||
message = f"Extension '{path}' not installed. Ask the admin to install it."
|
message = f"Extension '{path}' not installed. Ask the admin to install it."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from lnbits.fiat.base import FiatProvider
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .stripe import StripeWallet
|
||||||
|
|
||||||
|
fiat_module = importlib.import_module("lnbits.fiat")
|
||||||
|
|
||||||
|
|
||||||
|
class FiatProviderType(Enum):
|
||||||
|
stripe = "StripeWallet"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fiat_provider(name: str) -> FiatProvider:
|
||||||
|
if name not in FiatProviderType.__members__:
|
||||||
|
raise ValueError(f"Fiat provider '{name}' is not supported.")
|
||||||
|
|
||||||
|
fiat_provider = fiat_providers.get(name)
|
||||||
|
if fiat_provider:
|
||||||
|
status = await fiat_provider.status(only_check_settings=True)
|
||||||
|
if status.error_message:
|
||||||
|
await fiat_provider.cleanup()
|
||||||
|
del fiat_providers[name]
|
||||||
|
else:
|
||||||
|
return fiat_provider
|
||||||
|
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
|
||||||
|
return fiat_providers[name]
|
||||||
|
|
||||||
|
|
||||||
|
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
|
||||||
|
if not settings.is_fiat_provider_enabled(fiat_provider.name):
|
||||||
|
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
|
||||||
|
provider_constructor = getattr(fiat_module, fiat_provider.value)
|
||||||
|
return provider_constructor()
|
||||||
|
|
||||||
|
|
||||||
|
fiat_providers: dict[str, FiatProvider] = {}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StripeWallet",
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import AsyncGenerator, Coroutine
|
||||||
|
from typing import TYPE_CHECKING, NamedTuple
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FiatStatusResponse(NamedTuple):
|
||||||
|
error_message: str | None = None
|
||||||
|
balance: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FiatInvoiceResponse(NamedTuple):
|
||||||
|
ok: bool
|
||||||
|
checking_id: str | None = None # payment_hash, rpc_id
|
||||||
|
payment_request: str | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success(self) -> bool:
|
||||||
|
return self.ok is True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self) -> bool:
|
||||||
|
return self.ok is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> bool:
|
||||||
|
return self.ok is False
|
||||||
|
|
||||||
|
|
||||||
|
class FiatPaymentResponse(NamedTuple):
|
||||||
|
# when ok is None it means we don't know if this succeeded
|
||||||
|
ok: bool | None = None
|
||||||
|
checking_id: str | None = None # payment_hash, rcp_id
|
||||||
|
fee: float | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success(self) -> bool:
|
||||||
|
return self.ok is True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self) -> bool:
|
||||||
|
return self.ok is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> bool:
|
||||||
|
return self.ok is False
|
||||||
|
|
||||||
|
|
||||||
|
class FiatPaymentStatus(NamedTuple):
|
||||||
|
paid: bool | None = None
|
||||||
|
fee: float | None = None # todo: what fee is this?
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success(self) -> bool:
|
||||||
|
return self.paid is True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self) -> bool:
|
||||||
|
return self.paid is not True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> bool:
|
||||||
|
return self.paid is False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.success:
|
||||||
|
return "success"
|
||||||
|
if self.failed:
|
||||||
|
return "failed"
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||||
|
paid = True
|
||||||
|
|
||||||
|
|
||||||
|
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||||
|
paid = False
|
||||||
|
|
||||||
|
|
||||||
|
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||||
|
paid = None
|
||||||
|
|
||||||
|
|
||||||
|
class FiatProvider(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def cleanup(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def status(
|
||||||
|
self, only_check_settings: bool | None = False
|
||||||
|
) -> Coroutine[None, None, FiatStatusResponse]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_invoice(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
payment_hash: str,
|
||||||
|
currency: str,
|
||||||
|
memo: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def pay_invoice(
|
||||||
|
self,
|
||||||
|
payment_request: str,
|
||||||
|
) -> Coroutine[None, None, FiatPaymentResponse]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_invoice_status(
|
||||||
|
self, checking_id: str
|
||||||
|
) -> Coroutine[None, None, FiatPaymentStatus]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_payment_status(
|
||||||
|
self, checking_id: str
|
||||||
|
) -> Coroutine[None, None, FiatPaymentStatus]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def paid_invoices_stream(
|
||||||
|
self,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
yield ""
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.helpers import normalize_endpoint
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
FiatInvoiceResponse,
|
||||||
|
FiatPaymentFailedStatus,
|
||||||
|
FiatPaymentPendingStatus,
|
||||||
|
FiatPaymentResponse,
|
||||||
|
FiatPaymentStatus,
|
||||||
|
FiatPaymentSuccessStatus,
|
||||||
|
FiatProvider,
|
||||||
|
FiatStatusResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StripeWallet(FiatProvider):
|
||||||
|
"""https://docs.stripe.com/api"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
logger.debug("Initializing StripeWallet")
|
||||||
|
self._settings_fields = self._settings_connection_fields()
|
||||||
|
if not settings.stripe_api_endpoint:
|
||||||
|
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
||||||
|
|
||||||
|
if not settings.stripe_api_secret_key:
|
||||||
|
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
||||||
|
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
||||||
|
"User-Agent": settings.user_agent,
|
||||||
|
}
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||||
|
logger.info("StripeWallet initialized.")
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
try:
|
||||||
|
await self.client.aclose()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"Error closing stripe wallet connection: {e}")
|
||||||
|
|
||||||
|
async def status(
|
||||||
|
self, only_check_settings: Optional[bool] = False
|
||||||
|
) -> FiatStatusResponse:
|
||||||
|
if only_check_settings:
|
||||||
|
if self._settings_fields != self._settings_connection_fields():
|
||||||
|
return FiatStatusResponse("Connection settings have changed.", 0)
|
||||||
|
return FiatStatusResponse(balance=0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self.client.get(url="/v1/balance", timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
available_balance = data.get("available", [{}])[0].get("amount", 0)
|
||||||
|
# pending_balance = data.get("pending", {}).get("amount", 0)
|
||||||
|
|
||||||
|
return FiatStatusResponse(balance=available_balance)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
payment_hash: str,
|
||||||
|
currency: str,
|
||||||
|
memo: Optional[str] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> FiatInvoiceResponse:
|
||||||
|
amount_cents = int(amount * 100)
|
||||||
|
form_data = [
|
||||||
|
("mode", "payment"),
|
||||||
|
(
|
||||||
|
"success_url",
|
||||||
|
settings.stripe_payment_success_url or "https://lnbits.com",
|
||||||
|
),
|
||||||
|
("metadata[payment_hash]", payment_hash),
|
||||||
|
("line_items[0][price_data][currency]", currency.lower()),
|
||||||
|
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
|
||||||
|
("line_items[0][price_data][unit_amount]", amount_cents),
|
||||||
|
("line_items[0][quantity]", "1"),
|
||||||
|
]
|
||||||
|
encoded_data = urlencode(form_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = self.headers.copy()
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
r = await self.client.post(
|
||||||
|
url="/v1/checkout/sessions", headers=headers, content=encoded_data
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
session_id = data.get("id")
|
||||||
|
if not session_id:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: 'missing session id'"
|
||||||
|
)
|
||||||
|
payment_request = data.get("url")
|
||||||
|
if not payment_request:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: 'missing payment URL'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=True, checking_id=session_id, payment_request=payment_request
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: 'invalid json response'"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||||
|
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
|
try:
|
||||||
|
r = await self.client.get(
|
||||||
|
url=f"/v1/checkout/sessions/{checking_id}",
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
payment_status = data.get("payment_status")
|
||||||
|
if not payment_status:
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
if payment_status == "paid":
|
||||||
|
# todo: handle fee
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
|
||||||
|
expires_at = data.get("expires_at")
|
||||||
|
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
if expires_at and expires_at < _24_hours_ago.timestamp():
|
||||||
|
# be defensive: add a 24 hour buffer
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Error getting invoice status: {exc}")
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
|
raise NotImplementedError("Stripe does not support outgoing payments.")
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
logger.warning(
|
||||||
|
"Stripe does not support paid invoices stream. Use webhooks instead."
|
||||||
|
)
|
||||||
|
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||||
|
while settings.lnbits_running:
|
||||||
|
value = await mock_queue.get()
|
||||||
|
yield value
|
||||||
|
|
||||||
|
def _settings_connection_fields(self) -> str:
|
||||||
|
return "-".join(
|
||||||
|
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
||||||
|
)
|
||||||
+82
-67
@@ -3,7 +3,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, Optional
|
||||||
from urllib import request
|
from urllib import request
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ import jinja2
|
|||||||
import jwt
|
import jwt
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
from loguru import logger
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
from lnbits.nodes import get_node_class
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.requestvars import g
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.crypto import AESCipher
|
from lnbits.utils.crypto import AESCipher
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def urlsafe_short_hash() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
||||||
base = g().base_url if external else ""
|
base = f"http://{settings.host}:{settings.port}" if external else ""
|
||||||
url_params = "?"
|
url_params = "?"
|
||||||
for key, value in params.items():
|
for key, value in params.items():
|
||||||
url_params += f"{key}={value}&"
|
url_params += f"{key}={value}&"
|
||||||
@@ -64,63 +64,52 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
|||||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||||
t.env.globals["static_url_for"] = static_url_for
|
t.env.globals["static_url_for"] = static_url_for
|
||||||
|
|
||||||
if settings.lnbits_ad_space_enabled:
|
window_settings = {
|
||||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
"AD_SPACE": settings.lnbits_ad_space.split(","),
|
||||||
t.env.globals["AD_SPACE_TITLE"] = settings.lnbits_ad_space_title
|
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
|
||||||
|
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
|
||||||
|
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
|
||||||
|
"HIDE_API": settings.lnbits_hide_api,
|
||||||
|
"SITE_TITLE": settings.lnbits_site_title,
|
||||||
|
"SITE_TAGLINE": settings.lnbits_site_tagline,
|
||||||
|
"SITE_DESCRIPTION": settings.lnbits_site_description,
|
||||||
|
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
||||||
|
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
||||||
|
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
|
||||||
|
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
|
||||||
|
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
|
||||||
|
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
|
||||||
|
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
||||||
|
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
||||||
|
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
|
||||||
|
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
|
||||||
|
"LNBITS_NODE_UI": settings.lnbits_node_ui and get_node_class() is not None,
|
||||||
|
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None,
|
||||||
|
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
|
||||||
|
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
|
||||||
|
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
|
||||||
|
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||||
|
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||||
|
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||||
|
"LNBITS_VERSION": settings.version,
|
||||||
|
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||||
|
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||||
|
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||||
|
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||||
|
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||||
|
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||||
|
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||||
|
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||||
|
"LNBITS_DENOMINATION": (
|
||||||
|
settings.lnbits_denomination
|
||||||
|
if settings.lnbits_denomination == "FakeWallet"
|
||||||
|
else "sats"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
t.env.globals["VOIDWALLET"] = settings.lnbits_backend_wallet_class == "VoidWallet"
|
t.env.globals["WINDOW_SETTINGS"] = window_settings
|
||||||
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
|
for key, value in window_settings.items():
|
||||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
t.env.globals[key] = value
|
||||||
t.env.globals["LNBITS_DENOMINATION"] = (
|
|
||||||
settings.lnbits_denomination
|
|
||||||
if settings.lnbits_backend_wallet_class == "FakeWallet"
|
|
||||||
else "sats"
|
|
||||||
)
|
|
||||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
|
||||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
|
||||||
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
|
|
||||||
settings.lnbits_show_home_page_elements
|
|
||||||
)
|
|
||||||
t.env.globals["LNBITS_CUSTOM_IMAGE"] = settings.lnbits_custom_image
|
|
||||||
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
|
|
||||||
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
|
|
||||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
|
||||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
|
||||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
|
||||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
|
||||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
|
||||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
|
||||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
|
||||||
settings.lnbits_extensions_deactivate_all
|
|
||||||
)
|
|
||||||
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
|
|
||||||
|
|
||||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
|
||||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
|
||||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
|
||||||
t.env.globals["LNBITS_NODE_UI"] = (
|
|
||||||
settings.lnbits_node_ui and get_node_class() is not None
|
|
||||||
)
|
|
||||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
|
||||||
t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids)
|
|
||||||
|
|
||||||
if settings.lnbits_custom_logo:
|
|
||||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
|
||||||
|
|
||||||
if settings.lnbits_default_reaction:
|
|
||||||
t.env.globals["USE_DEFAULT_REACTION"] = settings.lnbits_default_reaction
|
|
||||||
|
|
||||||
if settings.lnbits_default_theme:
|
|
||||||
t.env.globals["USE_DEFAULT_THEME"] = settings.lnbits_default_theme
|
|
||||||
|
|
||||||
if settings.lnbits_default_border:
|
|
||||||
t.env.globals["USE_DEFAULT_BORDER"] = settings.lnbits_default_border
|
|
||||||
|
|
||||||
if settings.lnbits_default_gradient:
|
|
||||||
t.env.globals["USE_DEFAULT_GRADIENT"] = settings.lnbits_default_gradient
|
|
||||||
|
|
||||||
if settings.lnbits_default_bgimage:
|
|
||||||
t.env.globals["USE_DEFAULT_BGIMAGE"] = settings.lnbits_default_bgimage
|
|
||||||
|
|
||||||
if settings.bundle_assets:
|
if settings.bundle_assets:
|
||||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||||
@@ -134,8 +123,6 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
|||||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||||
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
||||||
|
|
||||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
|
||||||
|
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +153,7 @@ def get_current_extension_name() -> str:
|
|||||||
return ext_name
|
return ext_name
|
||||||
|
|
||||||
|
|
||||||
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
|
def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False):
|
||||||
"""
|
"""
|
||||||
Generate openapi documentation for Filters. This is intended to be used along
|
Generate openapi documentation for Filters. This is intended to be used along
|
||||||
parse_filters (see example)
|
parse_filters (see example)
|
||||||
@@ -210,6 +197,14 @@ def is_valid_username(username: str) -> bool:
|
|||||||
return re.fullmatch(username_regex, username) is not None
|
return re.fullmatch(username_regex, username) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_external_id(external_id: str) -> bool:
|
||||||
|
if len(external_id) > 256:
|
||||||
|
return False
|
||||||
|
if " " in external_id or "\n" in external_id:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def is_valid_pubkey(pubkey: str) -> bool:
|
def is_valid_pubkey(pubkey: str) -> bool:
|
||||||
if len(pubkey) != 64:
|
if len(pubkey) != 64:
|
||||||
return False
|
return False
|
||||||
@@ -294,12 +289,21 @@ def is_lnbits_version_ok(
|
|||||||
|
|
||||||
|
|
||||||
def check_callback_url(url: str):
|
def check_callback_url(url: str):
|
||||||
netloc = urlparse(url).netloc
|
if not settings.lnbits_callback_url_rules:
|
||||||
|
# no rules, all urls are allowed
|
||||||
|
return
|
||||||
|
u = urlparse(url)
|
||||||
for rule in settings.lnbits_callback_url_rules:
|
for rule in settings.lnbits_callback_url_rules:
|
||||||
if re.match(rule, netloc) is None:
|
try:
|
||||||
raise ValueError(
|
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
|
||||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
return
|
||||||
)
|
except re.error:
|
||||||
|
logger.debug(f"Invalid regex rule: '{rule}'. ")
|
||||||
|
continue
|
||||||
|
raise ValueError(
|
||||||
|
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
|
||||||
|
f"Please check your admin settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
def download_url(url, save_path):
|
||||||
@@ -358,3 +362,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
|
|||||||
# Prevent filename with subdirectories
|
# Prevent filename with subdirectories
|
||||||
file_path = image_folder / filename.split("/")[-1]
|
file_path = image_folder / filename.split("/")[-1]
|
||||||
return file_path.resolve()
|
return file_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||||
|
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||||
|
if add_proto:
|
||||||
|
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
|
||||||
|
return endpoint
|
||||||
|
endpoint = (
|
||||||
|
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||||
|
)
|
||||||
|
return endpoint
|
||||||
|
|||||||
+2
-2
@@ -58,9 +58,9 @@ class LnurlErrorResponseHandler(APIRoute):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"LnurlErrorResponse",
|
||||||
|
"LnurlErrorResponseHandler",
|
||||||
"decode",
|
"decode",
|
||||||
"encode",
|
"encode",
|
||||||
"handle",
|
"handle",
|
||||||
"LnurlErrorResponse",
|
|
||||||
"LnurlErrorResponseHandler",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any, List, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
def _response_by_accepted_type(
|
def _response_by_accepted_type(
|
||||||
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus
|
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
|
||||||
) -> Union[HTMLResponse, JSONResponse]:
|
) -> Union[HTMLResponse, JSONResponse]:
|
||||||
"""
|
"""
|
||||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import contextvars
|
|
||||||
import types
|
|
||||||
|
|
||||||
request_global = contextvars.ContextVar(
|
|
||||||
"request_global", default=types.SimpleNamespace()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def g() -> types.SimpleNamespace:
|
|
||||||
return request_global.get()
|
|
||||||
+83
-1
@@ -381,7 +381,7 @@ class SecuritySettings(LNbitsSettings):
|
|||||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||||
lnbits_callback_url_rules: list[str] = Field(
|
lnbits_callback_url_rules: list[str] = Field(
|
||||||
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
|
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||||
)
|
)
|
||||||
|
|
||||||
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
||||||
@@ -553,7 +553,44 @@ class BoltzFundingSource(LNbitsSettings):
|
|||||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||||
boltz_client_macaroon: str | None = Field(default=None)
|
boltz_client_macaroon: str | None = Field(default=None)
|
||||||
boltz_client_wallet: str | None = Field(default="lnbits")
|
boltz_client_wallet: str | None = Field(default="lnbits")
|
||||||
|
boltz_client_password: str = Field(default="")
|
||||||
boltz_client_cert: str | None = Field(default=None)
|
boltz_client_cert: str | None = Field(default=None)
|
||||||
|
boltz_mnemonic: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class StrikeFundingSource(LNbitsSettings):
|
||||||
|
strike_api_endpoint: str | None = Field(
|
||||||
|
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
|
||||||
|
)
|
||||||
|
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
class FiatProviderLimits(BaseModel):
|
||||||
|
# empty list means all users are allowed to receive payments via Stripe
|
||||||
|
allowed_users: list[str] = Field(default=[])
|
||||||
|
|
||||||
|
service_max_fee_sats: int = Field(default=0)
|
||||||
|
service_fee_percent: float = Field(default=0)
|
||||||
|
service_fee_wallet_id: str | None = Field(default=None)
|
||||||
|
|
||||||
|
service_min_amount_sats: int = Field(default=0)
|
||||||
|
service_max_amount_sats: int = Field(default=0)
|
||||||
|
service_faucet_wallet_id: str | None = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class StripeFiatProvider(LNbitsSettings):
|
||||||
|
stripe_enabled: bool = Field(default=False)
|
||||||
|
stripe_api_endpoint: str = Field(default="https://api.stripe.com")
|
||||||
|
stripe_api_secret_key: str | None = Field(default=None)
|
||||||
|
stripe_payment_success_url: str = Field(default="https://lnbits.com")
|
||||||
|
|
||||||
|
stripe_payment_webhook_url: str = Field(
|
||||||
|
default="https://your-lnbits-domain-here.com/api/v1/callback/stripe"
|
||||||
|
)
|
||||||
|
# Use this secret to verify that events come from Stripe.
|
||||||
|
stripe_webhook_signing_secret: str | None = Field(default=None)
|
||||||
|
|
||||||
|
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||||
|
|
||||||
|
|
||||||
class LightningSettings(LNbitsSettings):
|
class LightningSettings(LNbitsSettings):
|
||||||
@@ -580,6 +617,7 @@ class FundingSourcesSettings(
|
|||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
|
StrikeFundingSource,
|
||||||
):
|
):
|
||||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||||
# How long to wait for the payment to be confirmed before returning a pending status
|
# How long to wait for the payment to be confirmed before returning a pending status
|
||||||
@@ -587,6 +625,40 @@ class FundingSourcesSettings(
|
|||||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class FiatProvidersSettings(StripeFiatProvider):
|
||||||
|
|
||||||
|
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if a specific fiat provider is enabled.
|
||||||
|
"""
|
||||||
|
if not provider:
|
||||||
|
return False
|
||||||
|
if provider == "stripe":
|
||||||
|
return self.stripe_enabled
|
||||||
|
# Add checks for other fiat providers here as needed
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Returns a list of fiat payment methods allowed for the user.
|
||||||
|
"""
|
||||||
|
allowed_providers = []
|
||||||
|
if self.stripe_enabled and (
|
||||||
|
not self.stripe_limits.allowed_users
|
||||||
|
or user_id in self.stripe_limits.allowed_users
|
||||||
|
):
|
||||||
|
allowed_providers.append("stripe")
|
||||||
|
|
||||||
|
# Add other fiat providers here as needed
|
||||||
|
return allowed_providers
|
||||||
|
|
||||||
|
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||||
|
"""
|
||||||
|
Returns the limits for a specific fiat provider.
|
||||||
|
"""
|
||||||
|
return getattr(self, provider_name + "_limits", None)
|
||||||
|
|
||||||
|
|
||||||
class WebPushSettings(LNbitsSettings):
|
class WebPushSettings(LNbitsSettings):
|
||||||
lnbits_webpush_pubkey: str | None = Field(default=None)
|
lnbits_webpush_pubkey: str | None = Field(default=None)
|
||||||
lnbits_webpush_privkey: str | None = Field(default=None)
|
lnbits_webpush_privkey: str | None = Field(default=None)
|
||||||
@@ -659,6 +731,8 @@ class KeycloakAuthSettings(LNbitsSettings):
|
|||||||
keycloak_discovery_url: str = Field(default="")
|
keycloak_discovery_url: str = Field(default="")
|
||||||
keycloak_client_id: str = Field(default="")
|
keycloak_client_id: str = Field(default="")
|
||||||
keycloak_client_secret: str = Field(default="")
|
keycloak_client_secret: str = Field(default="")
|
||||||
|
keycloak_client_custom_org: str | None = Field(default=None)
|
||||||
|
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class AuditSettings(LNbitsSettings):
|
class AuditSettings(LNbitsSettings):
|
||||||
@@ -757,6 +831,7 @@ class EditableSettings(
|
|||||||
SecuritySettings,
|
SecuritySettings,
|
||||||
NotificationsSettings,
|
NotificationsSettings,
|
||||||
FundingSourcesSettings,
|
FundingSourcesSettings,
|
||||||
|
FiatProvidersSettings,
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
NodeUISettings,
|
NodeUISettings,
|
||||||
@@ -863,6 +938,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
"NWCWallet",
|
"NWCWallet",
|
||||||
|
"StrikeWallet",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -950,6 +1026,12 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
|||||||
def is_installed_extension_id(self, ext_id: str) -> bool:
|
def is_installed_extension_id(self, ext_id: str) -> bool:
|
||||||
return ext_id in self.lnbits_installed_extensions_ids
|
return ext_id in self.lnbits_installed_extensions_ids
|
||||||
|
|
||||||
|
def is_ready_to_install_extension_id(self, ext_id: str) -> bool:
|
||||||
|
return (
|
||||||
|
ext_id not in self.lnbits_installed_extensions_ids
|
||||||
|
and ext_id in self.lnbits_all_extensions_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SuperSettings(EditableSettings):
|
class SuperSettings(EditableSettings):
|
||||||
super_user: str
|
super_user: str
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -338,7 +338,7 @@ window.localisation.br = {
|
|||||||
node_balance: 'Saldo do Nó: {balance} sats',
|
node_balance: 'Saldo do Nó: {balance} sats',
|
||||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Reserve Percentual: {percent} %',
|
funding_reserve_percent: 'Reserve Percentual: {percent} %',
|
||||||
node_managment: 'Gerenciamento de Nós',
|
node_management: 'Gerenciamento de Nós',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
|
'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
|
||||||
toggle_node_ui: 'Interface do Nó',
|
toggle_node_ui: 'Interface do Nó',
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ window.localisation.cn = {
|
|||||||
node_balance: '节点余额:{balance} sats',
|
node_balance: '节点余额:{balance} sats',
|
||||||
lnbits_balance: 'LNbits 余额:{balance} sats',
|
lnbits_balance: 'LNbits 余额:{balance} sats',
|
||||||
funding_reserve_percent: '保留百分比: {percent} %',
|
funding_reserve_percent: '保留百分比: {percent} %',
|
||||||
node_managment: '节点管理',
|
node_management: '节点管理',
|
||||||
node_management_not_supported: '活动资金来源不支持节点管理',
|
node_management_not_supported: '活动资金来源不支持节点管理',
|
||||||
toggle_node_ui: '节点用户界面',
|
toggle_node_ui: '节点用户界面',
|
||||||
toggle_public_node_ui: '公共节点用户界面',
|
toggle_public_node_ui: '公共节点用户界面',
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ window.localisation.cs = {
|
|||||||
node_balance: 'Stav uzlu: {balance} sats',
|
node_balance: 'Stav uzlu: {balance} sats',
|
||||||
lnbits_balance: 'Zůstatek LNbits: {balance} sats',
|
lnbits_balance: 'Zůstatek LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Rezervovat procento: {percent} %',
|
funding_reserve_percent: 'Rezervovat procento: {percent} %',
|
||||||
node_managment: 'Správa uzlů',
|
node_management: 'Správa uzlů',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Správa uzlů není podporována aktivním zdrojem financování',
|
'Správa uzlů není podporována aktivním zdrojem financování',
|
||||||
toggle_node_ui: 'Uživatelské rozhraní uzlu',
|
toggle_node_ui: 'Uživatelské rozhraní uzlu',
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ window.localisation.de = {
|
|||||||
node_balance: 'Kontostand: {balance} Sats',
|
node_balance: 'Kontostand: {balance} Sats',
|
||||||
lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
|
lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
|
||||||
funding_reserve_percent: 'Reservieren Prozent: {percent} %',
|
funding_reserve_percent: 'Reservieren Prozent: {percent} %',
|
||||||
node_managment: 'Knotenverwaltung',
|
node_management: 'Knotenverwaltung',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
|
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
|
||||||
toggle_node_ui: 'Node-Benutzeroberfläche',
|
toggle_node_ui: 'Node-Benutzeroberfläche',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ window.localisation.en = {
|
|||||||
active_channels: 'Active Channels',
|
active_channels: 'Active Channels',
|
||||||
connect_peer: 'Connect Peer',
|
connect_peer: 'Connect Peer',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
|
reconnect: 'Reconnect',
|
||||||
open_channel: 'Open Channel',
|
open_channel: 'Open Channel',
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
close_channel: 'Close Channel',
|
close_channel: 'Close Channel',
|
||||||
@@ -49,14 +50,18 @@ window.localisation.en = {
|
|||||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||||
access_wallet_on_mobile: 'Mobile Access',
|
access_wallet_on_mobile: 'Mobile Access',
|
||||||
wallet: 'Wallet: ',
|
wallet: 'Wallet: ',
|
||||||
|
wallet_name: 'Wallet name',
|
||||||
wallets: 'Wallets',
|
wallets: 'Wallets',
|
||||||
add_wallet: 'Add a new wallet',
|
add_wallet: 'Add wallet',
|
||||||
|
add_new_wallet: 'Add a new wallet',
|
||||||
|
pin_wallet: 'Pin wallet',
|
||||||
delete_wallet: 'Delete wallet',
|
delete_wallet: 'Delete wallet',
|
||||||
delete_wallet_desc:
|
delete_wallet_desc:
|
||||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||||
rename_wallet: 'Rename wallet',
|
rename_wallet: 'Rename wallet',
|
||||||
update_name: 'Update name',
|
update_name: 'Update name',
|
||||||
fiat_tracking: 'Fiat tracking',
|
fiat_tracking: 'Fiat tracking',
|
||||||
|
fiat_providers: 'Fiat providers',
|
||||||
currency: 'Currency',
|
currency: 'Currency',
|
||||||
update_currency: 'Update currency',
|
update_currency: 'Update currency',
|
||||||
press_to_claim: 'Press to claim bitcoin',
|
press_to_claim: 'Press to claim bitcoin',
|
||||||
@@ -125,6 +130,7 @@ window.localisation.en = {
|
|||||||
no_extensions: "You don't have any extensions installed :(",
|
no_extensions: "You don't have any extensions installed :(",
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
search_extensions: 'Search extensions',
|
search_extensions: 'Search extensions',
|
||||||
|
search_wallets: 'Search wallets',
|
||||||
extension_sources: 'Extension Sources',
|
extension_sources: 'Extension Sources',
|
||||||
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
||||||
ext_sources_label:
|
ext_sources_label:
|
||||||
@@ -137,6 +143,7 @@ window.localisation.en = {
|
|||||||
uninstall: 'Uninstall',
|
uninstall: 'Uninstall',
|
||||||
drop_db: 'Remove Data',
|
drop_db: 'Remove Data',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
|
enabled: 'Enabled',
|
||||||
pay_to_enable: 'Pay To Enable',
|
pay_to_enable: 'Pay To Enable',
|
||||||
enable_extension_details: 'Enable extension for current user',
|
enable_extension_details: 'Enable extension for current user',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
@@ -167,12 +174,33 @@ window.localisation.en = {
|
|||||||
payment_hash: 'Payment Hash',
|
payment_hash: 'Payment Hash',
|
||||||
fee: 'Fee',
|
fee: 'Fee',
|
||||||
amount: 'Amount',
|
amount: 'Amount',
|
||||||
|
amount_limits: 'Amount Limits',
|
||||||
amount_sats: 'Amount (sats)',
|
amount_sats: 'Amount (sats)',
|
||||||
|
faucest_wallet: 'Faucet Wallet',
|
||||||
|
faucest_wallet_desc_1:
|
||||||
|
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
|
||||||
|
faucest_wallet_desc_2:
|
||||||
|
'This helps monitor all {provider} payments and their status.',
|
||||||
|
faucest_wallet_desc_3:
|
||||||
|
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
|
||||||
|
faucest_wallet_desc_4:
|
||||||
|
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
|
||||||
|
faucest_wallet_desc_5:
|
||||||
|
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
|
||||||
|
faucest_wallet_id: 'Faucet Wallet ID (optional)',
|
||||||
|
faucest_wallet_id_hint:
|
||||||
|
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
|
||||||
tag: 'Tag',
|
tag: 'Tag',
|
||||||
unit: 'Unit',
|
unit: 'Unit',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
expiry: 'Expiry',
|
expiry: 'Expiry',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
|
webhook_url: 'Webhook URL',
|
||||||
|
webhook_url_hint:
|
||||||
|
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
|
||||||
|
webhook_events_list: 'The following events must be supported by the webhook:',
|
||||||
|
webhook_stripe_description:
|
||||||
|
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
|
||||||
payment_proof: 'Payment Proof',
|
payment_proof: 'Payment Proof',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
update_available: 'Update {version} available!',
|
update_available: 'Update {version} available!',
|
||||||
@@ -264,6 +292,7 @@ window.localisation.en = {
|
|||||||
notification_source_label:
|
notification_source_label:
|
||||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||||
more: 'more',
|
more: 'more',
|
||||||
|
more_count: '{count} more',
|
||||||
less: 'less',
|
less: 'less',
|
||||||
releases: 'Releases',
|
releases: 'Releases',
|
||||||
watchdog: 'Watchdog',
|
watchdog: 'Watchdog',
|
||||||
@@ -278,7 +307,7 @@ window.localisation.en = {
|
|||||||
callback_url_rules: 'Callback URL Rules',
|
callback_url_rules: 'Callback URL Rules',
|
||||||
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
||||||
callback_url_rule_hint:
|
callback_url_rule_hint:
|
||||||
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
|
'Callback URLs (like LNURL one) will be validated against these rules. At leat one rule must match. No rule means all URLs are allowed.',
|
||||||
wallet_limiter: 'Wallet Limiter',
|
wallet_limiter: 'Wallet Limiter',
|
||||||
wallet_config: 'Wallet Config',
|
wallet_config: 'Wallet Config',
|
||||||
wallet_charts: 'Wallet Charts',
|
wallet_charts: 'Wallet Charts',
|
||||||
@@ -303,6 +332,9 @@ window.localisation.en = {
|
|||||||
login_with_user_id: 'Login with user ID',
|
login_with_user_id: 'Login with user ID',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
create_new_wallet: 'Create New Wallet',
|
create_new_wallet: 'Create New Wallet',
|
||||||
|
delete_all_wallets: 'Delete All Wallets',
|
||||||
|
confirm_delete_all_wallets:
|
||||||
|
'Are you sure you want to delete ALL wallets for this user?',
|
||||||
login_to_account: 'Login to your account',
|
login_to_account: 'Login to your account',
|
||||||
create_account: 'Create account',
|
create_account: 'Create account',
|
||||||
account_settings: 'Account Settings',
|
account_settings: 'Account Settings',
|
||||||
@@ -311,7 +343,7 @@ window.localisation.en = {
|
|||||||
signin_with_nostr: 'Continue with Nostr',
|
signin_with_nostr: 'Continue with Nostr',
|
||||||
signin_with_google: 'Sign in with Google',
|
signin_with_google: 'Sign in with Google',
|
||||||
signin_with_github: 'Sign in with GitHub',
|
signin_with_github: 'Sign in with GitHub',
|
||||||
signin_with_keycloak: 'Sign in with Keycloak',
|
signin_with_custom_org: 'Sign in with {custom_org}',
|
||||||
username_or_email: 'Username or Email',
|
username_or_email: 'Username or Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
password_config: 'Password Config',
|
password_config: 'Password Config',
|
||||||
@@ -320,14 +352,19 @@ window.localisation.en = {
|
|||||||
change_password: 'Change Password',
|
change_password: 'Change Password',
|
||||||
update_credentials: 'Update Credentials',
|
update_credentials: 'Update Credentials',
|
||||||
update_pubkey: 'Update Public Key',
|
update_pubkey: 'Update Public Key',
|
||||||
|
nostr_pubkey_tooltip: "Enter this user's Nostr public key (hex value)",
|
||||||
set_password: 'Set Password',
|
set_password: 'Set Password',
|
||||||
|
set_password_tooltip: 'Set a password for this user',
|
||||||
invalid_password: 'Password must have at least 8 characters',
|
invalid_password: 'Password must have at least 8 characters',
|
||||||
invalid_password_repeat: 'Passwords do not match',
|
invalid_password_repeat: 'Passwords do not match',
|
||||||
|
reset_key_generated: 'A reset key has been generated.',
|
||||||
|
reset_key_copy: 'Click OK to copy the reset URL to your clipboard.',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
register: 'Register',
|
register: 'Register',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
pubkey: 'Public Key',
|
pubkey: 'Public Key',
|
||||||
user_id: 'User ID',
|
user_id: 'User ID',
|
||||||
|
id: 'ID',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
first_name: 'First Name',
|
first_name: 'First Name',
|
||||||
last_name: 'Last Name',
|
last_name: 'Last Name',
|
||||||
@@ -337,12 +374,15 @@ window.localisation.en = {
|
|||||||
update_account: 'Update Account',
|
update_account: 'Update Account',
|
||||||
invalid_username: 'Invalid Username',
|
invalid_username: 'Invalid Username',
|
||||||
auth_provider: 'Auth Provider',
|
auth_provider: 'Auth Provider',
|
||||||
|
external_id: 'External ID',
|
||||||
my_account: 'My Account',
|
my_account: 'My Account',
|
||||||
existing_account_question: 'Already have an account?',
|
existing_account_question: 'Already have an account?',
|
||||||
background_image: 'Background Image',
|
background_image: 'Background Image',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
look_and_feel: 'Look and Feel',
|
look_and_feel: 'Look and Feel',
|
||||||
|
endpoint: 'Endpoint',
|
||||||
|
api: 'API',
|
||||||
api_token: 'API Token',
|
api_token: 'API Token',
|
||||||
api_tokens: 'API Tokens',
|
api_tokens: 'API Tokens',
|
||||||
access_control_list: 'Access Control List',
|
access_control_list: 'Access Control List',
|
||||||
@@ -354,11 +394,14 @@ window.localisation.en = {
|
|||||||
gradient_background: 'Gradient Background',
|
gradient_background: 'Gradient Background',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
color_scheme: 'Color Scheme',
|
color_scheme: 'Color Scheme',
|
||||||
|
visible_wallet_count: 'Visible Wallet Count',
|
||||||
admin_settings: 'Admin Settings',
|
admin_settings: 'Admin Settings',
|
||||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||||
release_details_error: 'Cannot get the release details.',
|
release_details_error: 'Cannot get the release details.',
|
||||||
pay_from_wallet: 'Pay from Wallet',
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
|
pay_with: 'Pay with {provider}',
|
||||||
|
select_payment_provider: 'Select payment provider',
|
||||||
wallet_required: 'Wallet *',
|
wallet_required: 'Wallet *',
|
||||||
show_qr: 'Show QR',
|
show_qr: 'Show QR',
|
||||||
retry_install: 'Retry Install',
|
retry_install: 'Retry Install',
|
||||||
@@ -371,6 +414,9 @@ window.localisation.en = {
|
|||||||
'The {name} extension requires a payment of minimum {amount} sats to enable.',
|
'The {name} extension requires a payment of minimum {amount} sats to enable.',
|
||||||
hide_empty_wallets: 'Hide empty wallets',
|
hide_empty_wallets: 'Hide empty wallets',
|
||||||
recheck: 'Recheck',
|
recheck: 'Recheck',
|
||||||
|
check: 'Check',
|
||||||
|
check_connection: 'Check Connection',
|
||||||
|
check_webhook: 'Check Webhook',
|
||||||
contributors: 'Contributors',
|
contributors: 'Contributors',
|
||||||
license: 'License',
|
license: 'License',
|
||||||
reset_key: 'Reset Key',
|
reset_key: 'Reset Key',
|
||||||
@@ -432,7 +478,7 @@ window.localisation.en = {
|
|||||||
node_balance: 'Node Balance: {balance} sats',
|
node_balance: 'Node Balance: {balance} sats',
|
||||||
lnbits_balance: 'LNbits Balance: {balance} sats',
|
lnbits_balance: 'LNbits Balance: {balance} sats',
|
||||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||||
node_managment: 'Node Management',
|
node_management: 'Node Management',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Node Management not supported by active funding source',
|
'Node Management not supported by active funding source',
|
||||||
toggle_node_ui: 'Node UI',
|
toggle_node_ui: 'Node UI',
|
||||||
@@ -444,7 +490,7 @@ window.localisation.en = {
|
|||||||
fee_reserve_percent: 'Fee Reserve Percent',
|
fee_reserve_percent: 'Fee Reserve Percent',
|
||||||
fee_reserve_msats: 'Reserve fee in msats',
|
fee_reserve_msats: 'Reserve fee in msats',
|
||||||
reserve_fee_in_percent: 'Reserve fee in percent',
|
reserve_fee_in_percent: 'Reserve fee in percent',
|
||||||
payment_wait_time: 'Payment Wait Time',
|
payment_wait_time: 'Payment Wait Time (sec)',
|
||||||
payment_wait_time_desc:
|
payment_wait_time_desc:
|
||||||
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
|
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
|
||||||
server_management: 'Server Management',
|
server_management: 'Server Management',
|
||||||
@@ -470,12 +516,16 @@ window.localisation.en = {
|
|||||||
auth_keycloak_ci_hint:
|
auth_keycloak_ci_hint:
|
||||||
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
||||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||||
|
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||||
|
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||||
currency_settings: 'Currency Settings',
|
currency_settings: 'Currency Settings',
|
||||||
allowed_currencies: 'Allowed Currencies',
|
allowed_currencies: 'Allowed Currencies',
|
||||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||||
default_account_currency: 'Default Account Currency',
|
default_account_currency: 'Default Account Currency',
|
||||||
default_account_currency_hint: 'Default currency for accounting',
|
default_account_currency_hint: 'Default currency for accounting',
|
||||||
|
min_incoming_payment_amount: 'Min Incoming Payment Amount',
|
||||||
|
min_incoming_payment_amount_desc:
|
||||||
|
'Minimum amount allowed for generating an invoice',
|
||||||
max_incoming_payment_amount: 'Max Incoming Payment Amount',
|
max_incoming_payment_amount: 'Max Incoming Payment Amount',
|
||||||
max_incoming_payment_amount_desc:
|
max_incoming_payment_amount_desc:
|
||||||
'Maximum amount allowed for generating an invoice',
|
'Maximum amount allowed for generating an invoice',
|
||||||
@@ -537,6 +587,7 @@ window.localisation.en = {
|
|||||||
admin_users_label: 'User ID',
|
admin_users_label: 'User ID',
|
||||||
allowed_users: 'Allowed Users',
|
allowed_users: 'Allowed Users',
|
||||||
allowed_users_hint: 'Only these users can use LNbits',
|
allowed_users_hint: 'Only these users can use LNbits',
|
||||||
|
allowed_users_hint_feature: 'Only these users can use {feature}',
|
||||||
allowed_users_label: 'User ID',
|
allowed_users_label: 'User ID',
|
||||||
allow_creation_user: 'Allow creation of new users',
|
allow_creation_user: 'Allow creation of new users',
|
||||||
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
||||||
@@ -571,5 +622,12 @@ window.localisation.en = {
|
|||||||
view_column: 'View wallets as rows',
|
view_column: 'View wallets as rows',
|
||||||
filter_payments: 'Filter payments',
|
filter_payments: 'Filter payments',
|
||||||
filter_date: 'Filter by date',
|
filter_date: 'Filter by date',
|
||||||
websocket_example: 'Websocket example'
|
websocket_example: 'Websocket example',
|
||||||
|
secret_key: 'Secret Key',
|
||||||
|
signing_secret: 'Signing Secret',
|
||||||
|
signing_secret_hint:
|
||||||
|
'Signing secret for the webhook. Messages will be signed with this secret.',
|
||||||
|
callback_success_url: 'Callback Success URL',
|
||||||
|
callback_success_url_hint:
|
||||||
|
'The user will be redirected to this URL after the payment is successful'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ window.localisation.es = {
|
|||||||
node_balance: 'Balance de Nodo: {balance} sats',
|
node_balance: 'Balance de Nodo: {balance} sats',
|
||||||
lnbits_balance: 'Saldo de LNbits: {balance} sats',
|
lnbits_balance: 'Saldo de LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
|
funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
|
||||||
node_managment: 'Gestión de nodos',
|
node_management: 'Gestión de nodos',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'La gestión de nodos no es compatible con la fuente de financiación activa',
|
'La gestión de nodos no es compatible con la fuente de financiación activa',
|
||||||
toggle_node_ui: 'Interfaz de usuario de nodo',
|
toggle_node_ui: 'Interfaz de usuario de nodo',
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ window.localisation.fi = {
|
|||||||
payment_processing: 'Maksua käsitellään...',
|
payment_processing: 'Maksua käsitellään...',
|
||||||
not_enough_funds: 'Varat eivät riitä!',
|
not_enough_funds: 'Varat eivät riitä!',
|
||||||
search_by_tag_memo_amount: 'Etsi tunnisteella, muistiolla tai määrällä',
|
search_by_tag_memo_amount: 'Etsi tunnisteella, muistiolla tai määrällä',
|
||||||
invoice_waiting: 'Lasku osottaa maksamista',
|
invoice_waiting: 'Lasku odottaa maksua',
|
||||||
payment_received: 'Maksu vastaanotettu',
|
payment_received: 'Maksu vastaanotettu',
|
||||||
payment_sent: 'Maksu lähetetty',
|
payment_sent: 'Maksu lähetetty',
|
||||||
payment_failed: 'Maksu epäonnistui',
|
payment_failed: 'Maksu epäonnistui',
|
||||||
@@ -261,7 +261,7 @@ window.localisation.fi = {
|
|||||||
notification_source: 'Tiedotteiden lähde',
|
notification_source: 'Tiedotteiden lähde',
|
||||||
notification_source_label:
|
notification_source_label:
|
||||||
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
|
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
|
||||||
more: 'laajenna',
|
more: 'näytä lisää',
|
||||||
less: 'supista',
|
less: 'supista',
|
||||||
releases: 'Julkaisut',
|
releases: 'Julkaisut',
|
||||||
watchdog: 'Watchdog',
|
watchdog: 'Watchdog',
|
||||||
@@ -321,6 +321,8 @@ window.localisation.fi = {
|
|||||||
set_password: 'Aseta salasana',
|
set_password: 'Aseta salasana',
|
||||||
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
|
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
|
||||||
invalid_password_repeat: 'Salasanat eivät täsmää',
|
invalid_password_repeat: 'Salasanat eivät täsmää',
|
||||||
|
reset_key_generated: 'Salasanan vaihtoavain on luotu.',
|
||||||
|
reset_key_copy: 'Kopioi vaihto-URL leikepöydälle painamalla OK.',
|
||||||
login: 'Kirjaudu',
|
login: 'Kirjaudu',
|
||||||
register: 'Rekisteröidy',
|
register: 'Rekisteröidy',
|
||||||
username: 'Käyttäjänimi',
|
username: 'Käyttäjänimi',
|
||||||
@@ -432,7 +434,7 @@ window.localisation.fi = {
|
|||||||
node_balance: 'Solmun saldo: {balance} sats',
|
node_balance: 'Solmun saldo: {balance} sats',
|
||||||
lnbits_balance: 'LNbits-saldo: {balance} sat',
|
lnbits_balance: 'LNbits-saldo: {balance} sat',
|
||||||
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
|
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
|
||||||
node_managment: 'Solmun hallinta',
|
node_management: 'Solmun hallinta',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
|
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
|
||||||
toggle_node_ui: 'Solmun käyttöliittymä',
|
toggle_node_ui: 'Solmun käyttöliittymä',
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ window.localisation.fr = {
|
|||||||
node_balance: 'Solde du nœud : {balance} sats',
|
node_balance: 'Solde du nœud : {balance} sats',
|
||||||
lnbits_balance: 'Solde LNbits : {balance} sats',
|
lnbits_balance: 'Solde LNbits : {balance} sats',
|
||||||
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
|
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
|
||||||
node_managment: 'Gestion des nœuds',
|
node_management: 'Gestion des nœuds',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
"La gestion des nœuds n'est pas prise en charge par la source de financement active",
|
"La gestion des nœuds n'est pas prise en charge par la source de financement active",
|
||||||
toggle_node_ui: 'Interface utilisateur de nœud',
|
toggle_node_ui: 'Interface utilisateur de nœud',
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ window.localisation.it = {
|
|||||||
node_balance: 'Saldo Nodo: {balance} sats',
|
node_balance: 'Saldo Nodo: {balance} sats',
|
||||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Riserva Percentuale: {percent} %',
|
funding_reserve_percent: 'Riserva Percentuale: {percent} %',
|
||||||
node_managment: 'Gestione dei nodi',
|
node_management: 'Gestione dei nodi',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
|
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
|
||||||
toggle_node_ui: 'Interfaccia utente del nodo',
|
toggle_node_ui: 'Interfaccia utente del nodo',
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ window.localisation.jp = {
|
|||||||
node_balance: 'ノード残高: {balance} サッツ',
|
node_balance: 'ノード残高: {balance} サッツ',
|
||||||
lnbits_balance: 'LNbits残高: {balance} sats',
|
lnbits_balance: 'LNbits残高: {balance} sats',
|
||||||
funding_reserve_percent: '予約パーセント: {percent} %',
|
funding_reserve_percent: '予約パーセント: {percent} %',
|
||||||
node_managment: 'ノード管理',
|
node_management: 'ノード管理',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'アクティブな資金源ではノード管理がサポートされていません',
|
'アクティブな資金源ではノード管理がサポートされていません',
|
||||||
toggle_node_ui: 'ノードUI',
|
toggle_node_ui: 'ノードUI',
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ window.localisation.kr = {
|
|||||||
node_balance: '노드 잔액: {balance} 사토시',
|
node_balance: '노드 잔액: {balance} 사토시',
|
||||||
lnbits_balance: 'LNbits 잔액: {balance} sats',
|
lnbits_balance: 'LNbits 잔액: {balance} sats',
|
||||||
funding_reserve_percent: '예약 비율: {percent} %',
|
funding_reserve_percent: '예약 비율: {percent} %',
|
||||||
node_managment: '노드 관리',
|
node_management: '노드 관리',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
|
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
|
||||||
toggle_node_ui: '노드 UI',
|
toggle_node_ui: '노드 UI',
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ window.localisation.nl = {
|
|||||||
node_balance: 'Node Balans: {balance} sats',
|
node_balance: 'Node Balans: {balance} sats',
|
||||||
lnbits_balance: 'LNbits Saldo: {balance} sats',
|
lnbits_balance: 'LNbits Saldo: {balance} sats',
|
||||||
funding_reserve_percent: 'Reservepercentage: {percent} %',
|
funding_reserve_percent: 'Reservepercentage: {percent} %',
|
||||||
node_managment: 'Nodebeheer',
|
node_management: 'Nodebeheer',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
|
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
|
||||||
toggle_node_ui: 'Node UI',
|
toggle_node_ui: 'Node UI',
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ window.localisation.pi = {
|
|||||||
node_balance: 'Node Balance: {balance} doubloons',
|
node_balance: 'Node Balance: {balance} doubloons',
|
||||||
lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
|
lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
|
||||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||||
node_managment: 'Node Management',
|
node_management: 'Node Management',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Node Management not be supported by active funding source',
|
'Node Management not be supported by active funding source',
|
||||||
toggle_node_ui: 'Node Main Deck',
|
toggle_node_ui: 'Node Main Deck',
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ window.localisation.pl = {
|
|||||||
node_balance: 'Saldo węzła: {balance} sats',
|
node_balance: 'Saldo węzła: {balance} sats',
|
||||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
|
funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
|
||||||
node_managment: 'Zarządzanie węzłami',
|
node_management: 'Zarządzanie węzłami',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
|
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
|
||||||
toggle_node_ui: 'Interfejs użytkownika węzła',
|
toggle_node_ui: 'Interfejs użytkownika węzła',
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ window.localisation.pt = {
|
|||||||
node_balance: 'Saldo do Nó: {balance} sats',
|
node_balance: 'Saldo do Nó: {balance} sats',
|
||||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Reserve Percentagem: {percent} %',
|
funding_reserve_percent: 'Reserve Percentagem: {percent} %',
|
||||||
node_managment: 'Gerenciamento de Nós',
|
node_management: 'Gerenciamento de Nós',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
|
'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
|
||||||
toggle_node_ui: 'Interface do Usuário de Nó',
|
toggle_node_ui: 'Interface do Usuário de Nó',
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ window.localisation.sk = {
|
|||||||
node_balance: 'Stav uzla: {balance} sats',
|
node_balance: 'Stav uzla: {balance} sats',
|
||||||
lnbits_balance: 'Zostatok LNbits: {balance} sats',
|
lnbits_balance: 'Zostatok LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Rezervovať percento: {percent} %',
|
funding_reserve_percent: 'Rezervovať percento: {percent} %',
|
||||||
node_managment: 'Správa uzlov',
|
node_management: 'Správa uzlov',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
|
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
|
||||||
toggle_node_ui: 'Používateľské rozhranie uzla',
|
toggle_node_ui: 'Používateľské rozhranie uzla',
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ window.localisation.we = {
|
|||||||
node_balance: 'Cydbwysedd Nôd: {balance} sats',
|
node_balance: 'Cydbwysedd Nôd: {balance} sats',
|
||||||
lnbits_balance: 'Cydbwysedd LNbits: {balance} sats',
|
lnbits_balance: 'Cydbwysedd LNbits: {balance} sats',
|
||||||
funding_reserve_percent: 'Cadw Canran: {percent} %',
|
funding_reserve_percent: 'Cadw Canran: {percent} %',
|
||||||
node_managment: 'Rheoli Nodau',
|
node_management: 'Rheoli Nodau',
|
||||||
node_management_not_supported:
|
node_management_not_supported:
|
||||||
'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol',
|
'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol',
|
||||||
toggle_node_ui: 'Node UI',
|
toggle_node_ui: 'Node UI',
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -54,6 +54,7 @@ window.AdminPageLogic = {
|
|||||||
chartReady: false,
|
chartReady: false,
|
||||||
formAddAdmin: '',
|
formAddAdmin: '',
|
||||||
formAddUser: '',
|
formAddUser: '',
|
||||||
|
formAddStripeUser: '',
|
||||||
hideInputToggle: true,
|
hideInputToggle: true,
|
||||||
formAddExtensionsManifest: '',
|
formAddExtensionsManifest: '',
|
||||||
nostrNotificationIdentifier: '',
|
nostrNotificationIdentifier: '',
|
||||||
@@ -187,6 +188,23 @@ window.AdminPageLogic = {
|
|||||||
let allowed_users = this.formData.lnbits_allowed_users
|
let allowed_users = this.formData.lnbits_allowed_users
|
||||||
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
|
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
|
||||||
},
|
},
|
||||||
|
addStripeAllowedUser() {
|
||||||
|
const addUser = this.formAddStripeUser || ''
|
||||||
|
if (
|
||||||
|
addUser.length &&
|
||||||
|
!this.formData.stripe_limits.allowed_users.includes(addUser)
|
||||||
|
) {
|
||||||
|
this.formData.stripe_limits.allowed_users = [
|
||||||
|
...this.formData.stripe_limits.allowed_users,
|
||||||
|
addUser
|
||||||
|
]
|
||||||
|
this.formAddStripeUser = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeStripeAllowedUser(user) {
|
||||||
|
this.formData.stripe_limits.allowed_users =
|
||||||
|
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
|
||||||
|
},
|
||||||
addIncludePath() {
|
addIncludePath() {
|
||||||
if (!this.formAddIncludePath) {
|
if (!this.formAddIncludePath) {
|
||||||
return
|
return
|
||||||
@@ -450,7 +468,7 @@ window.AdminPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
return moment(date * 1000).fromNow()
|
return moment.utc(date * 1000).fromNow()
|
||||||
},
|
},
|
||||||
sendTestEmail() {
|
sendTestEmail() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
@@ -546,10 +564,10 @@ window.AdminPageLogic = {
|
|||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message:
|
message:
|
||||||
'Success! Restored settings to defaults, restart required!',
|
'Success! Restored settings to defaults. Restarting...',
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
this.needsRestart = true
|
this.$q.localStorage.clear()
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
@@ -613,6 +631,20 @@ window.AdminPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
checkFiatProvider(providerName) {
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', `/api/v1/fiat/check/${providerName}`)
|
||||||
|
.then(response => {
|
||||||
|
response
|
||||||
|
const data = response.data
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: data.success ? 'positive' : 'warning',
|
||||||
|
message: data.message,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
downloadBackup() {
|
downloadBackup() {
|
||||||
window.open('/admin/api/v1/backup', '_blank')
|
window.open('/admin/api/v1/backup', '_blank')
|
||||||
},
|
},
|
||||||
|
|||||||
+27
-11
@@ -19,14 +19,16 @@ window.LNbits = {
|
|||||||
amount,
|
amount,
|
||||||
memo,
|
memo,
|
||||||
unit = 'sat',
|
unit = 'sat',
|
||||||
lnurlCallback = null
|
lnurlCallback = null,
|
||||||
|
fiatProvider = null
|
||||||
) {
|
) {
|
||||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||||
out: false,
|
out: false,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
memo: memo,
|
memo: memo,
|
||||||
lnurl_callback: lnurlCallback,
|
lnurl_callback: lnurlCallback,
|
||||||
unit: unit
|
unit: unit,
|
||||||
|
fiat_provider: fiatProvider
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
payInvoice(wallet, bolt11) {
|
payInvoice(wallet, bolt11) {
|
||||||
@@ -197,7 +199,9 @@ window.LNbits = {
|
|||||||
email: data.email,
|
email: data.email,
|
||||||
extensions: data.extensions,
|
extensions: data.extensions,
|
||||||
wallets: data.wallets,
|
wallets: data.wallets,
|
||||||
super_user: data.super_user
|
fiat_providers: data.fiat_providers || [],
|
||||||
|
super_user: data.super_user,
|
||||||
|
extra: data.extra ?? {}
|
||||||
}
|
}
|
||||||
const mapWallet = this.wallet
|
const mapWallet = this.wallet
|
||||||
obj.wallets = obj.wallets
|
obj.wallets = obj.wallets
|
||||||
@@ -205,6 +209,9 @@ window.LNbits = {
|
|||||||
return mapWallet(obj)
|
return mapWallet(obj)
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
if (a.extra.pinned !== b.extra.pinned) {
|
||||||
|
return a.extra.pinned ? -1 : 1
|
||||||
|
}
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
obj.walletOptions = obj.wallets.map(obj => {
|
obj.walletOptions = obj.wallets.map(obj => {
|
||||||
@@ -213,6 +220,10 @@ window.LNbits = {
|
|||||||
value: obj.id
|
value: obj.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
obj.hiddenWalletsCount = Math.max(
|
||||||
|
0,
|
||||||
|
data.wallets.length - data.extra.visible_wallet_count
|
||||||
|
)
|
||||||
return obj
|
return obj
|
||||||
},
|
},
|
||||||
wallet(data) {
|
wallet(data) {
|
||||||
@@ -252,13 +263,11 @@ window.LNbits = {
|
|||||||
fiat_currency: data.fiat_currency
|
fiat_currency: data.fiat_currency
|
||||||
}
|
}
|
||||||
|
|
||||||
obj.date = Quasar.date.formatDate(new Date(obj.time), window.dateFormat)
|
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||||
obj.dateFrom = moment(obj.date).fromNow()
|
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
||||||
obj.expirydate = Quasar.date.formatDate(
|
|
||||||
new Date(obj.expiry),
|
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||||
window.dateFormat
|
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||||
)
|
|
||||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra?.tag
|
obj.tag = obj.extra?.tag
|
||||||
@@ -495,8 +504,10 @@ window.windowMixin = {
|
|||||||
? this.$q.localStorage.getItem('lnbits.backgroundImage')
|
? this.$q.localStorage.getItem('lnbits.backgroundImage')
|
||||||
: USE_DEFAULT_BGIMAGE,
|
: USE_DEFAULT_BGIMAGE,
|
||||||
isUserAuthorized: false,
|
isUserAuthorized: false,
|
||||||
|
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
|
||||||
walletEventListeners: [],
|
walletEventListeners: [],
|
||||||
backgroundImage: ''
|
backgroundImage: '',
|
||||||
|
...WINDOW_SETTINGS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -508,6 +519,11 @@ window.windowMixin = {
|
|||||||
}
|
}
|
||||||
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
||||||
},
|
},
|
||||||
|
goToWallets() {
|
||||||
|
this.$router.push({
|
||||||
|
path: '/wallets'
|
||||||
|
})
|
||||||
|
},
|
||||||
submitAddWallet() {
|
submitAddWallet() {
|
||||||
if (
|
if (
|
||||||
this.showAddWalletDialog.name &&
|
this.showAddWalletDialog.name &&
|
||||||
|
|||||||
@@ -103,14 +103,7 @@ window.app.component('lnbits-extension-list', {
|
|||||||
window.app.component('lnbits-manage', {
|
window.app.component('lnbits-manage', {
|
||||||
mixins: [window.windowMixin],
|
mixins: [window.windowMixin],
|
||||||
template: '#lnbits-manage',
|
template: '#lnbits-manage',
|
||||||
props: [
|
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
|
||||||
'showAdmin',
|
|
||||||
'showNode',
|
|
||||||
'showExtensions',
|
|
||||||
'showUsers',
|
|
||||||
'showAudit',
|
|
||||||
'showPayments'
|
|
||||||
],
|
|
||||||
methods: {
|
methods: {
|
||||||
isActive(path) {
|
isActive(path) {
|
||||||
return window.location.pathname === path
|
return window.location.pathname === path
|
||||||
@@ -210,7 +203,7 @@ window.app.component('lnbits-qrcode', {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
custom: {
|
custom: {
|
||||||
margin: 1,
|
margin: 3,
|
||||||
width: 350,
|
width: 350,
|
||||||
size: 350,
|
size: 350,
|
||||||
logo: LNBITS_QR_LOGO
|
logo: LNBITS_QR_LOGO
|
||||||
@@ -566,7 +559,9 @@ window.app.component('username-password', {
|
|||||||
username: this.userName,
|
username: this.userName,
|
||||||
password: this.password_1,
|
password: this.password_1,
|
||||||
passwordRepeat: this.password_2,
|
passwordRepeat: this.password_2,
|
||||||
reset_key: this.resetKey
|
reset_key: this.resetKey,
|
||||||
|
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
|
||||||
|
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
fundingSource => fundingSource[0] === item
|
fundingSource => fundingSource[0] === item
|
||||||
)
|
)
|
||||||
return fundingSource ? fundingSource[1] : item
|
return fundingSource ? fundingSource[1] : item
|
||||||
|
},
|
||||||
|
showQRValue(value) {
|
||||||
|
this.qrValue = value
|
||||||
|
this.showQRDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -17,7 +21,8 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
const tmpObj = {}
|
const tmpObj = {}
|
||||||
if (obj !== null) {
|
if (obj !== null) {
|
||||||
for (let [k, v] of Object.entries(obj)) {
|
for (let [k, v] of Object.entries(obj)) {
|
||||||
tmpObj[k] = {label: v, value: null}
|
tmpObj[k] =
|
||||||
|
typeof v === 'string' ? {label: v, value: null} : v || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmp.push([key, tmpObj])
|
tmp.push([key, tmpObj])
|
||||||
@@ -31,6 +36,8 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
hideInput: true,
|
hideInput: true,
|
||||||
|
showQRDialog: false,
|
||||||
|
qrValue: '',
|
||||||
rawFundingSources: [
|
rawFundingSources: [
|
||||||
['VoidWallet', 'Void Wallet', null],
|
['VoidWallet', 'Void Wallet', null],
|
||||||
[
|
[
|
||||||
@@ -138,7 +145,14 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
boltz_client_endpoint: 'Endpoint',
|
boltz_client_endpoint: 'Endpoint',
|
||||||
boltz_client_macaroon: 'Admin Macaroon path or hex',
|
boltz_client_macaroon: 'Admin Macaroon path or hex',
|
||||||
boltz_client_cert: 'Certificate path or hex',
|
boltz_client_cert: 'Certificate path or hex',
|
||||||
boltz_client_wallet: 'Wallet Name'
|
boltz_client_wallet: 'Wallet Name',
|
||||||
|
boltz_client_password: 'Wallet Password (can be empty)',
|
||||||
|
boltz_mnemonic: {
|
||||||
|
label: 'Liquid mnemonic (copy into greenwallet)',
|
||||||
|
readonly: true,
|
||||||
|
copy: true,
|
||||||
|
qrcode: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -197,6 +211,14 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
||||||
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'StrikeWallet',
|
||||||
|
'Strike (alpha)',
|
||||||
|
{
|
||||||
|
strike_api_endpoint: 'API Endpoint',
|
||||||
|
strike_api_key: 'API Key'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ window.app = Vue.createApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.description = SITE_DESCRIPTION
|
this.description = this.SITE_DESCRIPTION
|
||||||
this.allowedRegister = ALLOWED_REGISTER
|
this.allowedRegister = this.LNBITS_NEW_ACCOUNTS_ALLOWED
|
||||||
this.authAction =
|
this.authAction =
|
||||||
!this.allowedRegister ||
|
!this.allowedRegister ||
|
||||||
Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
|
Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ const routes = [
|
|||||||
scripts: ['/static/js/account.js']
|
scripts: ['/static/js/account.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/wallets',
|
||||||
|
name: 'Wallets',
|
||||||
|
component: DynamicComponent,
|
||||||
|
props: {
|
||||||
|
fetchUrl: '/wallets',
|
||||||
|
scripts: ['/static/js/wallets.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/node',
|
path: '/node',
|
||||||
name: 'Node',
|
name: 'Node',
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
|||||||
return LNbits.utils.formatDate(this.ts)
|
return LNbits.utils.formatDate(this.ts)
|
||||||
},
|
},
|
||||||
dateFrom() {
|
dateFrom() {
|
||||||
return moment(this.date).fromNow()
|
return moment.utc(this.date).fromNow()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ window.PaymentsPageLogic = {
|
|||||||
searchData: {
|
searchData: {
|
||||||
wallet_id: null,
|
wallet_id: null,
|
||||||
payment_hash: null,
|
payment_hash: null,
|
||||||
status: null,
|
|
||||||
memo: null
|
memo: null
|
||||||
},
|
},
|
||||||
|
statusFilters: {
|
||||||
|
success: true,
|
||||||
|
pending: true,
|
||||||
|
failed: true,
|
||||||
|
incoming: true,
|
||||||
|
outgoing: true
|
||||||
|
},
|
||||||
chartData: {
|
chartData: {
|
||||||
showPaymentStatus: true,
|
showPaymentStatus: true,
|
||||||
showPaymentTags: true,
|
showPaymentTags: true,
|
||||||
@@ -131,6 +137,7 @@ window.PaymentsPageLogic = {
|
|||||||
if (this.searchDate.to) {
|
if (this.searchDate.to) {
|
||||||
filter['time[le]'] = this.searchDate.to + 'T23:59:59'
|
filter['time[le]'] = this.searchDate.to + 'T23:59:59'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.paymentsTable.filter = filter
|
this.paymentsTable.filter = filter
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -148,7 +155,7 @@ window.PaymentsPageLogic = {
|
|||||||
if (p.extra && p.extra.tag) {
|
if (p.extra && p.extra.tag) {
|
||||||
p.tag = p.extra.tag
|
p.tag = p.extra.tag
|
||||||
}
|
}
|
||||||
p.timeFrom = moment(p.created_at).fromNow()
|
p.timeFrom = moment.utc(p.created_at).fromNow()
|
||||||
p.outgoing = p.amount < 0
|
p.outgoing = p.amount < 0
|
||||||
p.amount =
|
p.amount =
|
||||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||||
@@ -198,7 +205,38 @@ window.PaymentsPageLogic = {
|
|||||||
|
|
||||||
this.fetchPayments()
|
this.fetchPayments()
|
||||||
},
|
},
|
||||||
|
handleFilterChanged() {
|
||||||
|
const {success, pending, failed, incoming, outgoing} = this.statusFilters
|
||||||
|
|
||||||
|
delete this.searchData['status[ne]']
|
||||||
|
delete this.searchData['status[eq]']
|
||||||
|
if (success && pending && failed) {
|
||||||
|
// do nothing, all statuses are selected
|
||||||
|
} else if (success && pending) {
|
||||||
|
this.searchData['status[ne]'] = 'failed'
|
||||||
|
} else if (success && failed) {
|
||||||
|
this.searchData['status[ne]'] = 'pending'
|
||||||
|
} else if (failed && pending) {
|
||||||
|
this.searchData['status[ne]'] = 'success'
|
||||||
|
} else if (success) {
|
||||||
|
this.searchData['status[eq]'] = 'success'
|
||||||
|
} else if (pending) {
|
||||||
|
this.searchData['status[eq]'] = 'pending'
|
||||||
|
} else if (failed) {
|
||||||
|
this.searchData['status[eq]'] = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.searchData['amount[ge]']
|
||||||
|
delete this.searchData['amount[le]']
|
||||||
|
if (incoming && outgoing) {
|
||||||
|
// do nothing
|
||||||
|
} else if (incoming) {
|
||||||
|
this.searchData['amount[ge]'] = '0'
|
||||||
|
} else if (outgoing) {
|
||||||
|
this.searchData['amount[le]'] = '0'
|
||||||
|
}
|
||||||
|
this.fetchPayments()
|
||||||
|
},
|
||||||
showDetailsToggle(payment) {
|
showDetailsToggle(payment) {
|
||||||
this.paymentDetails = payment
|
this.paymentDetails = payment
|
||||||
return (this.showDetails = !this.showDetails)
|
return (this.showDetails = !this.showDetails)
|
||||||
@@ -229,11 +267,10 @@ window.PaymentsPageLogic = {
|
|||||||
'GET',
|
'GET',
|
||||||
`/api/v1/payments/stats/count?${params}&count_by=status`
|
`/api/v1/payments/stats/count?${params}&count_by=status`
|
||||||
)
|
)
|
||||||
data.sort((a, b) => a.field - b.field)
|
|
||||||
this.searchOptions.status = data
|
data.sort((a, b) => a.field - b.field).reverse()
|
||||||
.map(s => s.field)
|
|
||||||
.sort()
|
this.searchOptions.status = data.map(s => s.field)
|
||||||
.reverse()
|
|
||||||
this.paymentsStatusChart.data.datasets[0].data = data.map(s => s.total)
|
this.paymentsStatusChart.data.datasets[0].data = data.map(s => s.total)
|
||||||
this.paymentsStatusChart.data.labels = [...this.searchOptions.status]
|
this.paymentsStatusChart.data.labels = [...this.searchOptions.status]
|
||||||
|
|
||||||
@@ -419,11 +456,8 @@ window.PaymentsPageLogic = {
|
|||||||
data: [],
|
data: [],
|
||||||
backgroundColor: [
|
backgroundColor: [
|
||||||
'rgb(0, 205, 86)',
|
'rgb(0, 205, 86)',
|
||||||
'rgb(54, 162, 235)',
|
'rgb(64, 72, 78)',
|
||||||
'rgb(255, 99, 132)',
|
'rgb(255, 99, 132)'
|
||||||
'rgb(255, 5, 86)',
|
|
||||||
'rgb(25, 205, 86)',
|
|
||||||
'rgb(255, 205, 250)'
|
|
||||||
],
|
],
|
||||||
hoverOffset: 4
|
hoverOffset: 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ window.UsersPageLogic = {
|
|||||||
.then(res => {
|
.then(res => {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog(
|
.confirmDialog(
|
||||||
'A reset key has been generated. Click OK to copy the rest key to your clipboard.'
|
this.$t('reset_key_generated') + ' ' + this.$t('reset_key_copy')
|
||||||
)
|
)
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
const url = window.location.origin + '?reset_key=' + res.data
|
const url = window.location.origin + '?reset_key=' + res.data
|
||||||
@@ -312,6 +312,23 @@ window.UsersPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
deleteAllUserWallets(userId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(this.$t('confirm_delete_all_wallets'))
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('DELETE', `/users/api/v1/user/${userId}/wallets`)
|
||||||
|
.then(response => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: response.data.message,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.fetchWallets(userId)
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
copyWalletLink(walletId) {
|
copyWalletLink(walletId) {
|
||||||
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
||||||
this.copyText(url)
|
this.copyText(url)
|
||||||
|
|||||||
+17
-15
@@ -35,6 +35,7 @@ window.WalletPageLogic = {
|
|||||||
lnurl: null,
|
lnurl: null,
|
||||||
units: ['sat'],
|
units: ['sat'],
|
||||||
unit: 'sat',
|
unit: 'sat',
|
||||||
|
fiatProvider: '',
|
||||||
data: {
|
data: {
|
||||||
amount: null,
|
amount: null,
|
||||||
memo: ''
|
memo: ''
|
||||||
@@ -253,15 +254,20 @@ window.WalletPageLogic = {
|
|||||||
this.receive.data.amount,
|
this.receive.data.amount,
|
||||||
this.receive.data.memo,
|
this.receive.data.memo,
|
||||||
this.receive.unit,
|
this.receive.unit,
|
||||||
this.receive.lnurl && this.receive.lnurl.callback
|
this.receive.lnurl && this.receive.lnurl.callback,
|
||||||
|
this.receive.fiatProvider
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.g.updatePayments = !this.g.updatePayments
|
this.g.updatePayments = !this.g.updatePayments
|
||||||
this.receive.status = 'success'
|
this.receive.status = 'success'
|
||||||
this.receive.paymentReq = response.data.bolt11
|
this.receive.paymentReq = response.data.bolt11
|
||||||
|
this.receive.fiatPaymentReq =
|
||||||
|
response.data.extra?.fiat_payment_request
|
||||||
this.receive.amountMsat = response.data.amount
|
this.receive.amountMsat = response.data.amount
|
||||||
this.receive.paymentHash = response.data.payment_hash
|
this.receive.paymentHash = response.data.payment_hash
|
||||||
this.readNfcTag()
|
if (!this.receive.lnurl) {
|
||||||
|
this.readNfcTag()
|
||||||
|
}
|
||||||
// TODO: lnurl_callback and lnurl_response
|
// TODO: lnurl_callback and lnurl_response
|
||||||
// WITHDRAW
|
// WITHDRAW
|
||||||
if (response.data.lnurl_response !== null) {
|
if (response.data.lnurl_response !== null) {
|
||||||
@@ -336,9 +342,6 @@ window.WalletPageLogic = {
|
|||||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||||
this.g.wallet.adminkey
|
this.g.wallet.adminkey
|
||||||
)
|
)
|
||||||
.catch(err => {
|
|
||||||
LNbits.utils.notifyApiError(err)
|
|
||||||
})
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const data = response.data
|
const data = response.data
|
||||||
|
|
||||||
@@ -376,6 +379,9 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
decodeQR(res) {
|
decodeQR(res) {
|
||||||
this.parse.data.request = res[0].rawValue
|
this.parse.data.request = res[0].rawValue
|
||||||
@@ -449,8 +455,8 @@ window.WalletPageLogic = {
|
|||||||
createdDate,
|
createdDate,
|
||||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||||
)
|
)
|
||||||
cleanInvoice.expireDateFrom = moment(expireDate).fromNow()
|
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
||||||
cleanInvoice.createdDateFrom = moment(createdDate).fromNow()
|
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
||||||
|
|
||||||
cleanInvoice.expired = false // TODO
|
cleanInvoice.expired = false // TODO
|
||||||
}
|
}
|
||||||
@@ -660,7 +666,7 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
message: 'Wallet and user updated.',
|
message: 'Wallet updated.',
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
})
|
})
|
||||||
@@ -818,6 +824,9 @@ window.WalletPageLogic = {
|
|||||||
},
|
},
|
||||||
swapBalancePriority() {
|
swapBalancePriority() {
|
||||||
this.isFiatPriority = !this.isFiatPriority
|
this.isFiatPriority = !this.isFiatPriority
|
||||||
|
this.receive.unit = this.isFiatPriority
|
||||||
|
? this.g.wallet.currency || 'sat'
|
||||||
|
: 'sat'
|
||||||
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
|
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
|
||||||
},
|
},
|
||||||
handleFiatTracking() {
|
handleFiatTracking() {
|
||||||
@@ -1112,13 +1121,6 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'g.wallet.id'(newVal, oldVal) {
|
|
||||||
try {
|
|
||||||
this.fetchChartData()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Chart creation failed: ${error}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'g.updatePayments'(newVal, oldVal) {
|
'g.updatePayments'(newVal, oldVal) {
|
||||||
this.parse.show = false
|
this.parse.show = false
|
||||||
if (this.receive.paymentHash === this.g.updatePaymentsHash) {
|
if (this.receive.paymentHash === this.g.updatePaymentsHash) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
window.WalletsPageLogic = {
|
||||||
|
mixins: [window.windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
tab: 'wallets',
|
||||||
|
wallets: [],
|
||||||
|
showAddWalletDialog: {show: false},
|
||||||
|
walletsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'currency',
|
||||||
|
align: 'center',
|
||||||
|
label: 'Currency',
|
||||||
|
field: 'currency',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updated_at',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Last Updated',
|
||||||
|
field: 'updated_at',
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
sortBy: 'updated_at',
|
||||||
|
rowsPerPage: 12,
|
||||||
|
page: 1,
|
||||||
|
descending: true,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
search: '',
|
||||||
|
hideEmpty: true,
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'walletsTable.search': {
|
||||||
|
handler() {
|
||||||
|
const props = {}
|
||||||
|
if (this.walletsTable.search) {
|
||||||
|
props['search'] = this.walletsTable.search
|
||||||
|
}
|
||||||
|
this.getUserWallets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getUserWallets(props) {
|
||||||
|
try {
|
||||||
|
this.walletsTable.loading = true
|
||||||
|
const params = LNbits.utils.prepareFilterQuery(this.walletsTable, props)
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/wallet/paginated?${params}`,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
this.wallets = data.data
|
||||||
|
this.walletsTable.pagination.rowsNumber = data.total
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.walletsTable.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToWallet(walletId) {
|
||||||
|
window.location = `/wallet?wal=${walletId}`
|
||||||
|
},
|
||||||
|
formattedFiatAmount(amount, currency) {
|
||||||
|
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
|
||||||
|
},
|
||||||
|
formattedSatAmount(amount) {
|
||||||
|
return LNbits.utils.formatMsat(amount) + ' sat'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
await this.getUserWallets()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user