Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55484b9cb4 | ||
|
|
4db7d20e60 | ||
|
|
02fec90a29 | ||
|
|
4efe1a9fca | ||
|
|
cba8c02d68 | ||
|
|
5c9d375713 | ||
|
|
264d1fa07c | ||
|
|
fc3e2b2b4d | ||
|
|
64edeebaaf | ||
|
|
c2739188d0 | ||
|
|
3f4d778c95 | ||
|
|
c1788c7219 | ||
|
|
07c62285c3 | ||
|
|
841c8ce3a9 | ||
|
|
cc6430221c | ||
|
|
4a34548864 | ||
|
|
4a3a3a0425 | ||
|
|
bf8b33a7b2 | ||
|
|
7e08211887 | ||
|
|
0bbb0aca2d | ||
|
|
9b1418b1f4 | ||
|
|
5f0a8d78e0 | ||
|
|
2fe8ae1cb1 | ||
|
|
1a3d23bbee | ||
|
|
ae45c1b958 | ||
|
|
8337351d6a | ||
|
|
f4dbd369be | ||
|
|
974ee26224 | ||
|
|
e4fb4453c3 | ||
|
|
aa7129a351 | ||
|
|
aba6e4cd87 | ||
|
|
f4e7d0c70c | ||
|
|
4247b9d9a3 | ||
|
|
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 |
+33
-5
@@ -40,7 +40,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# 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, CLNRestWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -61,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
#CLNRest (using runes)
|
||||
CLNREST_URL=https://127.0.0.1:3010
|
||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
||||
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
|
||||
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
|
||||
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
|
||||
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
|
||||
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
|
||||
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
|
||||
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
@@ -101,10 +115,16 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
@@ -147,6 +167,12 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
|
||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||
# BREEZ_USE_TRAMPOLINE=true
|
||||
|
||||
# BreezLiquidSdkWallet
|
||||
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
|
||||
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
|
||||
# BREEZ_LIQUID_API_KEY=""
|
||||
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
|
||||
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
|
||||
|
||||
# Google OAuth Config
|
||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
@@ -163,14 +189,16 @@ GITHUB_CLIENT_SECRET=""
|
||||
KEYCLOAK_CLIENT_ID=""
|
||||
KEYCLOAK_CLIENT_SECRET=""
|
||||
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
|
||||
# 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
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
|
||||
@@ -5,9 +5,6 @@ inputs:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.10"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.8.5"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "20.x"
|
||||
@@ -27,10 +24,8 @@ runs:
|
||||
# cache poetry install via pip
|
||||
cache: "pip"
|
||||
|
||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||
- name: Set up Poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ inputs.poetry-version }}
|
||||
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
shell: bash
|
||||
@@ -48,9 +43,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
poetry env use python${{ inputs.python-version }}
|
||||
poetry install
|
||||
# needed for conv tests
|
||||
poetry add psycopg2-binary
|
||||
poetry install --all-extras
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
|
||||
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
|
||||
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
||||
export LNBITS_DATA_FOLDER
|
||||
export LNBITS_EXTENSIONS_PATH
|
||||
export LNBITS_ADMIN_UI=true
|
||||
|
||||
# Define the LNbits URL
|
||||
URL="http://0.0.0.0:5000"
|
||||
|
||||
@@ -88,8 +88,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
|
||||
@@ -51,3 +51,14 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/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
|
||||
default: "3.10"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
required: true
|
||||
default: "1.5.1"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
|
||||
@@ -25,6 +25,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -7,6 +7,7 @@ __pycache__
|
||||
.mypy_cache
|
||||
.vscode
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
*.egg
|
||||
*.egg-info
|
||||
|
||||
+5
-4
@@ -4,13 +4,14 @@ RUN apt-get clean
|
||||
RUN apt-get update
|
||||
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"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy the files required to install the dependencies
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
@@ -20,7 +21,7 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -33,7 +34,7 @@ RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||
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 POETRY_NO_INTERACTION=1 \
|
||||
@@ -55,4 +56,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
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
|
||||
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
|
||||
./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.
|
||||
@@ -159,6 +159,14 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -10,6 +10,25 @@ 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:
|
||||
|
||||
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||
|
||||
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
||||
|
||||
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
|
||||
|
||||
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
|
||||
- `CLNREST_URL`: `https://127.0.0.1:3010`
|
||||
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
|
||||
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
|
||||
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
|
||||
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
|
||||
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
|
||||
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
|
||||
|
||||
### CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
@@ -17,6 +36,8 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
|
||||
### CoreLightning REST
|
||||
|
||||
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
@@ -133,6 +154,18 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
|
||||
### Breez Liquid SDK
|
||||
|
||||
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
|
||||
- `BREEZ_LIQUID_SEED`: ...
|
||||
|
||||
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
|
||||
|
||||
- `LNBITS_RESERVE_FEE_MIN`: ...
|
||||
- `LNBITS_RESERVE_FEE_PERCENT`: ...
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
@@ -6,15 +6,15 @@ if [ ! -d lnbits/data ]; then
|
||||
# Update package list and install prerequisites non-interactively
|
||||
sudo apt update -y
|
||||
sudo apt install -y software-properties-common
|
||||
|
||||
|
||||
# Add the deadsnakes PPA repository non-interactively
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install Python 3.9 and distutils non-interactively
|
||||
sudo apt install -y python3.9 python3.9-distutils
|
||||
|
||||
# Install Python 3.10 and distutils non-interactively
|
||||
sudo apt install -y python3.10 python3.10-distutils
|
||||
|
||||
# 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
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
@@ -51,4 +51,4 @@ export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
poetry run lnbits
|
||||
|
||||
+5
-8
@@ -9,16 +9,13 @@ from .decorators import (
|
||||
from .exceptions import InvoiceError, PaymentError
|
||||
|
||||
__all__ = [
|
||||
# decorators
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
# services
|
||||
"pay_invoice",
|
||||
"create_invoice",
|
||||
# exceptions
|
||||
"PaymentError",
|
||||
"InvoiceError",
|
||||
"pay_invoice",
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
]
|
||||
|
||||
+6
-12
@@ -27,6 +27,7 @@ from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
@@ -65,12 +66,7 @@ from .middleware import (
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
@@ -171,8 +167,6 @@ def create_app() -> FastAPI:
|
||||
name="library",
|
||||
)
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
@@ -280,7 +274,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list(
|
||||
async def build_all_installed_extensions_list( # noqa: C901
|
||||
include_deactivated: Optional[bool] = True,
|
||||
) -> list[InstallableExtension]:
|
||||
"""
|
||||
@@ -454,7 +448,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
@@ -464,12 +458,12 @@ async def check_and_register_extensions(app: FastAPI):
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def register_async_tasks():
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
+17
-11
@@ -320,9 +320,8 @@ async def extensions_list():
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
|
||||
for ext in await build_all_installed_extensions_list():
|
||||
assert (
|
||||
ext.meta and ext.meta.installed_release
|
||||
), f"Extension {ext.id} has no installed_release"
|
||||
if not ext.meta or not ext.meta.installed_release:
|
||||
raise ValueError(f"Extension {ext.id} has no installed_release")
|
||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
||||
|
||||
|
||||
@@ -353,7 +352,7 @@ async def extensions_list():
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@coro
|
||||
async def extensions_update(
|
||||
async def extensions_update( # noqa: C901
|
||||
extension: Optional[str] = None,
|
||||
all_extensions: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
@@ -518,12 +517,15 @@ def encrypt_macaroon():
|
||||
|
||||
@encrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||
def encrypt_aes(payload: str):
|
||||
@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())
|
||||
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting payload: {ex}")
|
||||
return
|
||||
@@ -533,12 +535,15 @@ def encrypt_aes(payload: str):
|
||||
|
||||
@decrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||
def decrypt_aes(payload: str):
|
||||
@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)
|
||||
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error decrypting payload: {ex}")
|
||||
return
|
||||
@@ -602,9 +607,10 @@ async def update_extension(
|
||||
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.meta and installed_ext.meta.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
if not installed_ext.meta or not installed_ext.meta.installed_release:
|
||||
raise ValueError(
|
||||
"Cannot find previously installed release. Please uninstall first."
|
||||
)
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
|
||||
@@ -5,7 +5,9 @@ from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.fiat_api import fiat_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router
|
||||
@@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(callback_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(fiat_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -85,87 +85,78 @@ from .webpush import (
|
||||
)
|
||||
|
||||
__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",
|
||||
"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",
|
||||
"reset_core_settings",
|
||||
# tinyurl
|
||||
"create_tinyurl",
|
||||
"delete_tinyurl",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
# users
|
||||
"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_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_by_email",
|
||||
"get_account_by_pubkey",
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_user",
|
||||
"get_user_from_account",
|
||||
"get_user_access_control_lists",
|
||||
"update_account",
|
||||
# wallets
|
||||
"create_wallet",
|
||||
"delete_unused_wallets",
|
||||
"delete_wallet",
|
||||
"delete_wallet_by_id",
|
||||
"force_delete_wallet",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_super_settings",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
"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_for_key",
|
||||
"get_wallet_payment",
|
||||
"get_wallets",
|
||||
"remove_deleted_wallets",
|
||||
"update_wallet",
|
||||
# webpush
|
||||
"create_webpush_subscription",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"get_webpush_subscription",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -30,10 +30,11 @@ async def delete_expired_audit_entries(
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE from audit
|
||||
WHERE delete_at < {db.timestamp_now}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
)
|
||||
|
||||
|
||||
@@ -48,13 +49,16 @@ async def get_count_stats(
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
data = await (conn or db).fetchall(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count({field}) as total
|
||||
FROM audit
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
@@ -70,13 +74,15 @@ async def get_long_duration_stats(
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
long_duration_paths = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT path as field, max(duration) as total FROM audit
|
||||
{clause}
|
||||
GROUP BY path
|
||||
ORDER BY total DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
|
||||
@@ -50,7 +50,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
|
||||
{"id": ext_id},
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert row, f"Extension '{ext_id}' db version cannot be found"
|
||||
if not row:
|
||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
@@ -78,10 +79,13 @@ async def get_installed_extensions(
|
||||
active: Optional[bool] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[InstallableExtension]:
|
||||
where = "WHERE active = :active" if active is not None else ""
|
||||
query = "SELECT * FROM installed_extensions"
|
||||
if active is not None:
|
||||
query += " WHERE active = :active"
|
||||
|
||||
values = {"active": active} if active is not None else {}
|
||||
all_extensions = await (conn or db).fetchall(
|
||||
f"SELECT * FROM installed_extensions {where}",
|
||||
query,
|
||||
values,
|
||||
model=InstallableExtension,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
@@ -19,8 +19,13 @@ from ..models import (
|
||||
)
|
||||
|
||||
|
||||
def update_payment_extra():
|
||||
pass
|
||||
async def update_payment_extra(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
) -> Payment:
|
||||
return await (conn or db).execute(
|
||||
"UPDATE apipayments SET extra = :extra WHERE checking_id = :checking_id",
|
||||
{"extra": payment.extra, "checking_id": payment.checking_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
||||
@@ -50,11 +55,13 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount LIMIT 1
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values,
|
||||
Payment,
|
||||
)
|
||||
@@ -80,19 +87,25 @@ async def get_latest_payments_by_extension(
|
||||
ext_name: str, ext_id: str, limit: int = 5
|
||||
) -> list[Payment]:
|
||||
return await db.fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The limtit is an integer and not user input
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
WHERE status = :status
|
||||
AND extra LIKE :ext_name
|
||||
AND extra LIKE :ext_id
|
||||
ORDER BY time DESC LIMIT {int(limit)}
|
||||
""",
|
||||
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||
""", # noqa: S608
|
||||
{
|
||||
"status": f"{PaymentState.SUCCESS}",
|
||||
"ext_name": f"%{ext_name}%",
|
||||
"ext_id": f"%{ext_id}%",
|
||||
},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_payments_paginated(
|
||||
async def get_payments_paginated( # noqa: C901
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
@@ -123,12 +136,8 @@ async def get_payments_paginated(
|
||||
values["wallet_id"] = wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
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]
|
||||
clause.append(f""" wallet_id IN ({", ".join(wallet_ids_str)}) """)
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
clause.append(only_user_wallets)
|
||||
|
||||
if complete and pending:
|
||||
clause.append(
|
||||
@@ -231,21 +240,23 @@ async def delete_expired_invoices(
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""",
|
||||
{"delta": int(time() - 2592000)},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""",
|
||||
{"now": int(time())},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
@@ -258,7 +269,8 @@ async def create_payment(
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
if previous_payment is not None:
|
||||
raise ValueError("Payment already exists")
|
||||
extra = data.extra or {}
|
||||
|
||||
payment = Payment(
|
||||
@@ -272,7 +284,7 @@ async def create_payment(
|
||||
preimage=data.preimage,
|
||||
expiry=data.expiry,
|
||||
webhook=data.webhook,
|
||||
fee=data.fee,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
)
|
||||
@@ -325,16 +337,20 @@ async def get_payments_history(
|
||||
)
|
||||
"""
|
||||
]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
# - `date_trunc` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
{clause}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
""", # noqa: S608
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
@@ -366,20 +382,29 @@ async def get_payments_history(
|
||||
async def get_payment_count_stats(
|
||||
field: PaymentCountField,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not 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(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count(*) as total
|
||||
FROM apipayments
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentCountStat,
|
||||
)
|
||||
@@ -389,18 +414,25 @@ async def get_payment_count_stats(
|
||||
|
||||
async def get_daily_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
in_clause = filters.where(
|
||||
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||
)
|
||||
out_clause = filters.where(
|
||||
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
|
||||
)
|
||||
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||
out_where_stmts = [
|
||||
"(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")
|
||||
query = """
|
||||
SELECT {date_trunc} date,
|
||||
@@ -431,9 +463,9 @@ async def get_daily_stats(
|
||||
|
||||
async def get_wallets_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -449,9 +481,15 @@ 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)
|
||||
|
||||
data = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clauses` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT apipayments.wallet_id,
|
||||
MAX(wallets.name) AS wallet_name,
|
||||
@@ -463,7 +501,7 @@ async def get_wallets_stats(
|
||||
{clauses}
|
||||
GROUP BY apipayments.wallet_id
|
||||
ORDER BY payments_count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentWalletStats,
|
||||
)
|
||||
@@ -488,11 +526,11 @@ async def check_internal(
|
||||
otherwise None
|
||||
"""
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE payment_hash = :hash AND status = :status AND amount > 0
|
||||
""",
|
||||
{"hash": payment_hash},
|
||||
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
|
||||
@@ -524,3 +562,14 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||
""",
|
||||
{"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)}) """
|
||||
|
||||
@@ -56,7 +56,8 @@ async def update_admin_settings(
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
await set_settings_field("super_user", super_user)
|
||||
settings = await get_super_settings()
|
||||
assert settings, "updated super_user settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("updated super_user settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
@@ -86,7 +87,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
|
||||
await set_settings_field(key, value)
|
||||
|
||||
settings = await get_super_settings()
|
||||
assert settings, "created admin settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("created admin settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ async def get_accounts(
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.external_id,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -109,6 +110,7 @@ async def delete_accounts_no_wallets(
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
@@ -117,7 +119,7 @@ async def delete_accounts_no_wallets(
|
||||
(updated_at is null AND created_at < :delta)
|
||||
OR updated_at < {db.timestamp_placeholder("delta")}
|
||||
)
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"delta": delta},
|
||||
)
|
||||
|
||||
@@ -128,8 +130,8 @@ async def get_account_by_username(
|
||||
if len(username) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE username = :username",
|
||||
{"username": username},
|
||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -138,8 +140,8 @@ async def get_account_by_pubkey(
|
||||
pubkey: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
||||
{"pubkey": pubkey},
|
||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||
{"pubkey": pubkey.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -150,8 +152,8 @@ async def get_account_by_email(
|
||||
if len(email) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :email",
|
||||
{"email": email},
|
||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||
{"email": email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -160,8 +162,11 @@ async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -183,6 +188,7 @@ async def get_user_from_account(
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
external_id=account.external_id,
|
||||
extra=account.extra,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
@@ -190,6 +196,7 @@ async def get_user_from_account(
|
||||
wallets=wallets,
|
||||
admin=account.is_admin,
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
+51
-22
@@ -4,7 +4,8 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
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 ..models import Wallet
|
||||
@@ -47,11 +48,12 @@ async def delete_wallet(
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet AND "user" = :user
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
||||
)
|
||||
|
||||
@@ -70,11 +72,12 @@ async def delete_wallet_by_id(
|
||||
) -> Optional[int]:
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "now": now},
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -106,14 +109,16 @@ async def delete_unused_wallets(
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet {where}
|
||||
""",
|
||||
query,
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
@@ -122,28 +127,52 @@ async def get_wallet(
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> list[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
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 ""
|
||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += "AND deleted = :deleted"
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT id FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
@@ -37,7 +37,8 @@ async def create_webpush_subscription(
|
||||
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
if not subscription:
|
||||
raise ValueError("Newly created webpush subscription couldn't be retrieved")
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
@@ -222,7 +223,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
""" # noqa: S608
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if len(rows):
|
||||
@@ -242,13 +243,15 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||
WHERE checking_id = :checking_id AND amount > 0
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"expiry": expiration_date, "checking_id": checking_id},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -371,7 +374,8 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
"wallet": row.get("id"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -454,17 +458,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
||||
# set all to now where they are null
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
@@ -616,7 +622,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
logger.info(f"Updating {offset} to {offset+limit}")
|
||||
|
||||
result = await db.execute(
|
||||
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
|
||||
# Limit and Offset safe from SQL injection
|
||||
# since they are integers and are not user input
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
|
||||
""" # noqa: S608
|
||||
)
|
||||
payments = result.mappings().all()
|
||||
logger.info(f"Payments count: {len(payments)}")
|
||||
@@ -629,11 +640,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
tag = extra.get("tag")
|
||||
tsph = db.timestamp_placeholder("created_at")
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
||||
WHERE checking_id = :checking_id
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{
|
||||
"tag": tag,
|
||||
"created_at": created_at,
|
||||
@@ -711,3 +723,15 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
Adds icon and color columns to wallets.
|
||||
"""
|
||||
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
|
||||
|
||||
__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",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"UserAcls",
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"CreateInvoice",
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"CreatePayment",
|
||||
"CreateUser",
|
||||
"RegisterUser",
|
||||
"CreateWallet",
|
||||
"CreateWebPushSubscription",
|
||||
"DbVersion",
|
||||
"DecodePayment",
|
||||
"KeyType",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"PayInvoice",
|
||||
"PayLnurlWData",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
"PaymentWalletStats",
|
||||
"PaymentsStatusCount",
|
||||
"RegisterUser",
|
||||
"ResetUserPassword",
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
"UpdateUserPubkey",
|
||||
"User",
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
# wallets
|
||||
"BaseWallet",
|
||||
"CreateWallet",
|
||||
"KeyType",
|
||||
"Wallet",
|
||||
"WalletTypeInfo",
|
||||
# webpush
|
||||
"CreateWebPushSubscription",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -211,7 +211,8 @@ class ExtensionRelease(BaseModel):
|
||||
self, amount: int | None = None
|
||||
) -> ReleasePaymentInfo | None:
|
||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
||||
assert url, "Missing URL for payment info."
|
||||
if not url:
|
||||
raise ValueError("Missing URL for payment info.")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
@@ -376,9 +377,8 @@ class InstallableExtension(BaseModel):
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert (
|
||||
self.meta and self.meta.installed_release
|
||||
), "installed_release is none."
|
||||
if not self.meta or not self.meta.installed_release:
|
||||
raise ValueError("No installed release.")
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class CreateLnurl(BaseModel):
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
internal_memo: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import bcrypt
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -11,15 +9,6 @@ def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolveBugBcryptWarning:
|
||||
__version__: str = getattr(bcrypt, "__version__") # noqa
|
||||
|
||||
|
||||
# fix annoying warning in the logs
|
||||
setattr(bcrypt, "__about__", SolveBugBcryptWarning()) # noqa
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
@@ -8,6 +8,13 @@ from fastapi import Query
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
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.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
@@ -60,6 +67,8 @@ class Payment(BaseModel):
|
||||
amount: int
|
||||
fee: int
|
||||
bolt11: str
|
||||
# payment_request: str | None
|
||||
fiat_provider: str | None = None
|
||||
status: str = PaymentState.PENDING
|
||||
memo: str | None = None
|
||||
expiry: datetime | None = None
|
||||
@@ -107,14 +116,23 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
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.success:
|
||||
return PaymentSuccessStatus()
|
||||
if self.failed:
|
||||
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()
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
@@ -123,6 +141,39 @@ class Payment(BaseModel):
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
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):
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
@@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_callback: str | None = None
|
||||
fiat_provider: str | None = None
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Keycloak SSO Login Helper
|
||||
"""
|
||||
"""Keycloak SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.misc import SimpleItem
|
||||
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 .wallets import Wallet
|
||||
@@ -27,6 +32,9 @@ class UserExtra(BaseModel):
|
||||
# - "google | github | ...": the user was created using an SSO 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):
|
||||
path: str
|
||||
@@ -90,6 +98,7 @@ class UserAcls(BaseModel):
|
||||
|
||||
class Account(BaseModel):
|
||||
id: str
|
||||
external_id: str | None = None # for external account linking
|
||||
username: str | None = None
|
||||
password_hash: 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_admin: bool = Field(default=False, no_database=True)
|
||||
fiat_providers: list[str] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self.is_super_user = settings.is_super_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:
|
||||
"""sets and returns the hashed password"""
|
||||
@@ -127,6 +138,11 @@ class Account(BaseModel):
|
||||
raise ValueError("Invalid email.")
|
||||
if self.pubkey and not is_valid_pubkey(self.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)
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
@@ -140,7 +156,14 @@ class AccountOverview(Account):
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
||||
__search_fields__ = [
|
||||
"user",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"email",
|
||||
@@ -154,6 +177,7 @@ class AccountFilters(FilterModel):
|
||||
user: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
|
||||
|
||||
@@ -164,10 +188,12 @@ class User(BaseModel):
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None # for external account linking
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
|
||||
@@ -204,13 +230,13 @@ class CreateUser(BaseModel):
|
||||
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)
|
||||
pubkey: str = Query(default=None, max_length=64)
|
||||
external_id: str = Query(default=None, max_length=256)
|
||||
extensions: list[str] | None = None
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: str | None = Query(default=None)
|
||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from enum import Enum
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
@@ -83,3 +85,13 @@ class KeyType(Enum):
|
||||
class WalletTypeInfo:
|
||||
key_type: KeyType
|
||||
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,16 @@ from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
create_invoice,
|
||||
create_payment_request,
|
||||
create_wallet_invoice,
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
service_fee,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
)
|
||||
@@ -30,36 +35,34 @@ from .users import (
|
||||
from .websockets import websocket_manager, websocket_updater
|
||||
|
||||
__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",
|
||||
"check_admin_settings",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"create_invoice",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"pay_invoice",
|
||||
"service_fee",
|
||||
"update_pending_payments",
|
||||
"update_wallet_balance",
|
||||
# settings
|
||||
"check_webpush_settings",
|
||||
"update_cached_settings",
|
||||
# users
|
||||
"check_admin_settings",
|
||||
"create_fiat_invoice",
|
||||
"create_invoice",
|
||||
"create_payment_request",
|
||||
"create_user_account",
|
||||
"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_extensions",
|
||||
# websockets
|
||||
"update_wallet_balance",
|
||||
"websocket_manager",
|
||||
"websocket_updater",
|
||||
]
|
||||
|
||||
@@ -26,10 +26,11 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
|
||||
if ext_info.meta.installed_release:
|
||||
assert (
|
||||
ext_info.meta.installed_release.is_version_compatible
|
||||
), "Incompatible extension version"
|
||||
if (
|
||||
ext_info.meta.installed_release
|
||||
and not ext_info.meta.installed_release.is_version_compatible
|
||||
):
|
||||
raise ValueError("Incompatible extension version")
|
||||
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
@@ -93,9 +94,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
stop_fn_name = f"{ext_id}_stop"
|
||||
assert hasattr(
|
||||
old_module, stop_fn_name
|
||||
), f"No stop function found for '{ext.module_name}'."
|
||||
if not hasattr(old_module, stop_fn_name):
|
||||
raise ValueError(f"No stop function found for '{ext.module_name}'.")
|
||||
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
|
||||
@@ -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}.",
|
||||
)
|
||||
@@ -67,16 +67,16 @@ async def redeem_lnurl_withdraw(
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(res["callback"])
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
@@ -136,7 +136,8 @@ async def perform_lnurlauth(
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
if not key.verifying_key:
|
||||
raise ValueError("LNURLauth verifying_key does not exist")
|
||||
check_callback_url(callback)
|
||||
r = await client.get(
|
||||
callback,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
from typing import Tuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -48,7 +47,7 @@ async def send_nostr_dm(
|
||||
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("@")
|
||||
if not identifier or not domain:
|
||||
raise ValueError("Invalid NIP5 identifier")
|
||||
|
||||
@@ -4,7 +4,7 @@ import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -28,7 +28,7 @@ from lnbits.helpers import check_callback_url, is_valid_email_address
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import normalize_private_key
|
||||
|
||||
notifications_queue: asyncio.Queue = asyncio.Queue()
|
||||
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
|
||||
|
||||
|
||||
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
@@ -42,8 +42,8 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
logger.error(f"Error enqueuing notification: {e}")
|
||||
|
||||
|
||||
async def process_next_notification():
|
||||
notification_message: NotificationMessage = await notifications_queue.get()
|
||||
async def process_next_notification() -> None:
|
||||
notification_message = await notifications_queue.get()
|
||||
message_type, text = _notification_message_to_text(notification_message)
|
||||
await send_notification(text, message_type)
|
||||
|
||||
@@ -186,7 +186,7 @@ def is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> Tuple[str, str]:
|
||||
) -> tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
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.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
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.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.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
@@ -61,7 +66,8 @@ async def pay_invoice(
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
assert invoice.amount_msat
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
@@ -84,8 +90,140 @@ async def pay_invoice(
|
||||
|
||||
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:
|
||||
await _credit_service_fee_wallet(payment, new_conn)
|
||||
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def create_payment_request(
|
||||
wallet_id: str, invoice_data: CreateInvoice
|
||||
) -> Payment:
|
||||
"""
|
||||
Create a lightning invoice or a fiat payment request.
|
||||
"""
|
||||
if invoice_data.fiat_provider:
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -163,6 +301,7 @@ async def create_invoice(
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
fee=payment_response.fee_msat or 0,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -184,17 +323,45 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(payment: Payment) -> bool:
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
return True
|
||||
if status.success:
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
return True
|
||||
return False
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
return payment
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
"""
|
||||
check_pending_payments is called during startup to check for pending payments with
|
||||
the backend and also to delete expired invoices. Incoming payments will be
|
||||
checked only once, outgoing pending payments will be checked regularly.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
logger.warning("Task: skipping pending check for VoidWallet")
|
||||
return
|
||||
start_time = time.time()
|
||||
pending_payments = await get_payments(
|
||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||
complete=False,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
count = len(pending_payments)
|
||||
if count > 0:
|
||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
||||
for i, payment in enumerate(pending_payments):
|
||||
payment = await update_pending_payment(payment)
|
||||
prefix = f"payment ({i+1} / {count})"
|
||||
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
logger.info(
|
||||
f"Task: pending check finished for {count} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
@@ -222,6 +389,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
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(
|
||||
wallet: Wallet,
|
||||
amount: int,
|
||||
@@ -280,10 +467,7 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
@@ -399,8 +583,9 @@ async def check_transaction_status(
|
||||
|
||||
async def get_payments_daily_stats(
|
||||
filters: Filters[PaymentFilters],
|
||||
user_id: Optional[str] = None,
|
||||
) -> 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
|
||||
|
||||
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
||||
@@ -566,57 +751,55 @@ async def _pay_external_invoice(
|
||||
)
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
payment_response = await asyncio.wait_for(task, wait_time)
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||
logger.debug(
|
||||
f"payment timeout after {wait_time}s, {checking_id} is still pending"
|
||||
)
|
||||
return payment
|
||||
|
||||
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
||||
f" {payment_response.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 failed
|
||||
if (
|
||||
payment_response.checking_id is None
|
||||
or payment_response.ok is False
|
||||
or payment_response.checking_id != checking_id
|
||||
):
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment_response.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
status="failed",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
f" database: {checking_id}"
|
||||
)
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
if payment_response.success:
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def update_payment_success_status(
|
||||
payment: Payment,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
if status.success:
|
||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -650,8 +833,7 @@ async def _verify_external_payment(
|
||||
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
await update_payment_success_status(payment, status, conn=conn)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
@@ -704,7 +886,7 @@ def _validate_payment_request(
|
||||
|
||||
|
||||
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)
|
||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||
@@ -715,7 +897,7 @@ async def _credit_service_fee_wallet(
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=abs(service_fee_msat),
|
||||
memo="Service fee",
|
||||
memo=memo,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"service_fee_{payment.payment_hash}",
|
||||
@@ -723,3 +905,47 @@ async def _credit_service_fee_wallet(
|
||||
status=PaymentState.SUCCESS,
|
||||
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.",
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ async def check_webpush_settings():
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
if not vapid.public_key:
|
||||
raise ValueError("VAPID public key does not exist")
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
|
||||
@@ -80,7 +80,8 @@ async def create_user_account_no_ckeck(
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
assert user, "Cannot find user for account."
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
+12
-11
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Callable, Coroutine
|
||||
from collections.abc import Coroutine
|
||||
from typing import Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -12,7 +13,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.payments import get_payments_status_count
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.models import AuditEntry
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import (
|
||||
@@ -30,10 +31,10 @@ from lnbits.settings import settings
|
||||
from lnbits.tasks import create_unique_task
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def run_by_the_minute_tasks():
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
@@ -68,7 +69,7 @@ async def run_by_the_minute_tasks():
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def _notify_server_status():
|
||||
async def _notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
@@ -89,7 +90,7 @@ async def _notify_server_status():
|
||||
enqueue_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
@@ -102,13 +103,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data():
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data: AuditEntry = await audit_queue.get()
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
@@ -116,7 +117,7 @@ async def wait_for_audit_data():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages():
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
@@ -126,7 +127,7 @@ async def wait_notification_messages():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data():
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
@@ -140,7 +141,7 @@ async def purge_audit_data():
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data():
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_discovery_url"
|
||||
@@ -141,7 +141,7 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_id"
|
||||
@@ -159,6 +159,22 @@
|
||||
>
|
||||
</q-input>
|
||||
</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>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
|
||||
@@ -145,7 +145,9 @@
|
||||
type="text"
|
||||
:label="$t('ad_slots_label')"
|
||||
:hint="$t('ad_slots_hint')"
|
||||
>
|
||||
><q-tooltip>
|
||||
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
|
||||
</q-tooltip>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
@@ -154,9 +156,9 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<p><span v-text="$t('ui_default_theme')"></span></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col">
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12" v-text="$t('ui_default_theme')"></div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_border"
|
||||
:options="globalBorderOptions"
|
||||
@@ -165,7 +167,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_theme"
|
||||
:options="lnbits_theme_options"
|
||||
@@ -174,7 +176,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_reaction"
|
||||
:options="reactionOptions"
|
||||
@@ -183,7 +185,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-input
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_bgimage"
|
||||
@@ -193,7 +195,7 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-toggle
|
||||
type="bool"
|
||||
v-model="formData.lnbits_default_gradient"
|
||||
|
||||
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="fiat_providers"
|
||||
icon="credit_score"
|
||||
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('fiat_providers')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
@@ -171,23 +178,27 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
||||
{% include "admin/_tab_exchange_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
<q-scroll-area style="height: 100vh">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
scroll
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
||||
"admin/_tab_fiat_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-splitter>
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.adminkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.adminkey"
|
||||
:options="{ width: 250 }"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -70,6 +81,17 @@
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.inkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.inkey"
|
||||
:options="{ width: 250 }"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -272,10 +272,21 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.external_id"
|
||||
:label="$t('external_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="user.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
@@ -335,95 +346,111 @@
|
||||
</q-btn-dropdown>
|
||||
</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="col-4">
|
||||
<span v-text="$t('color_scheme')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('classic')"
|
||||
v-if="allowedThemes.includes('classic')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('classic')"
|
||||
@click="changeTheme('classic')"
|
||||
icon="circle"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
><q-tooltip>classic</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
v-if="allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('bitcoin')"
|
||||
@click="changeTheme('bitcoin')"
|
||||
icon="circle"
|
||||
color="deep-orange"
|
||||
size="md"
|
||||
><q-tooltip>bitcoin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
v-if="allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('mint')"
|
||||
@click="changeTheme('mint')"
|
||||
icon="circle"
|
||||
color="green"
|
||||
size="md"
|
||||
><q-tooltip>mint</q-tooltip> </q-btn
|
||||
><q-btn
|
||||
v-if="g.allowedThemes.includes('autumn')"
|
||||
v-if="allowedThemes.includes('autumn')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('autumn')"
|
||||
@click="changeTheme('autumn')"
|
||||
icon="circle"
|
||||
color="brown"
|
||||
size="md"
|
||||
><q-tooltip>autumn</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('monochrome')"
|
||||
v-if="allowedThemes.includes('monochrome')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('monochrome')"
|
||||
@click="changeTheme('monochrome')"
|
||||
icon="circle"
|
||||
color="grey"
|
||||
size="md"
|
||||
><q-tooltip>monochrome</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('salvador')"
|
||||
v-if="allowedThemes.includes('salvador')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('salvador')"
|
||||
@click="changeTheme('salvador')"
|
||||
icon="circle"
|
||||
color="blue-10"
|
||||
size="md"
|
||||
><q-tooltip>elSalvador</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
v-if="allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('freedom')"
|
||||
@click="changeTheme('freedom')"
|
||||
icon="circle"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
><q-tooltip>freedom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('cyber')"
|
||||
v-if="allowedThemes.includes('cyber')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('cyber')"
|
||||
@click="changeTheme('cyber')"
|
||||
icon="circle"
|
||||
color="light-green-9"
|
||||
size="md"
|
||||
><q-tooltip>cyber</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
v-if="allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('flamingo')"
|
||||
@click="changeTheme('flamingo')"
|
||||
icon="circle"
|
||||
color="pink-3"
|
||||
size="md"
|
||||
@@ -437,9 +464,9 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="bgimageSelection"
|
||||
v-model="bgimageChoice"
|
||||
:label="$t('background_image')"
|
||||
@update:model-value="bgimageChoiceFunc"
|
||||
@update:model-value="applyBackgroundImage"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('background_image')"></span
|
||||
@@ -452,19 +479,18 @@
|
||||
<span v-text="$t('gradient_background')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
<q-toggle
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleGradient"
|
||||
icon="gradient"
|
||||
size="sm"
|
||||
v-model="gradientChoice"
|
||||
@update:model-value="applyGradient"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -473,10 +499,11 @@
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
<q-toggle
|
||||
dense
|
||||
flat
|
||||
round
|
||||
v-model="darkChoice"
|
||||
@click="toggleDarkMode"
|
||||
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||
size="sm"
|
||||
@@ -484,7 +511,7 @@
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_darkmode')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
@@ -493,7 +520,7 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="borderSelection"
|
||||
v-model="borderChoice"
|
||||
:options="borderOptions"
|
||||
label="Borders"
|
||||
@update:model-value="applyBorder"
|
||||
@@ -521,7 +548,7 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionSelection"
|
||||
v-model="reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="reactionChoiceFunc"
|
||||
@@ -532,6 +559,12 @@
|
||||
</q-select>
|
||||
</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 name="api_acls">
|
||||
<div class="row q-mb-md">
|
||||
|
||||
@@ -166,11 +166,12 @@
|
||||
v-if="authAction === 'login' && allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
Not registered? Create an
|
||||
<span
|
||||
Not registered?
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer"
|
||||
@click="showRegister('username-password')"
|
||||
>Account</span
|
||||
@click.prevent="showRegister('username-password')"
|
||||
>Create an Account</a
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
@@ -182,11 +183,12 @@
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<span
|
||||
class="text-secondary cursor-pointer"
|
||||
@click="showLogin('username-password')"
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click.prevent="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></span>
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
|
||||
@@ -211,7 +211,12 @@
|
||||
</q-card>
|
||||
<div id="hiddenQrCodeContainer" style="display: none">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,6 +247,23 @@
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em v-text="g.wallet.name"></em></strong>
|
||||
</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>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
@@ -602,14 +624,30 @@
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% else %}
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
:label="$t('unit')"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
: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
|
||||
ref="setAmount"
|
||||
filled
|
||||
@@ -627,9 +665,87 @@
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="receive.data.memo"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
v-model="receive.data.memo"
|
||||
:label="$t('memo')"
|
||||
>
|
||||
<template v-if="receive.data.internalMemo === null" v-slot:append>
|
||||
<q-icon
|
||||
name="add_comment"
|
||||
@click.stop.prevent="receive.data.internalMemo = ''"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('internal_memo')"></span>
|
||||
</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="receive.data.internalMemo !== null"
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="receive.data.internalMemo"
|
||||
class="q-mb-lg"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_receive')"
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="receive.data.internalMemo = null"
|
||||
class="cursor-pointer"
|
||||
/> </template
|
||||
></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">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -663,7 +779,14 @@
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
@@ -674,25 +797,27 @@
|
||||
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
||||
<span v-text="formattedSatAmount"></span>
|
||||
</h5>
|
||||
<q-chip v-if="hasNfc" outline square color="positive">
|
||||
<q-avatar
|
||||
icon="nfc"
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="$t('nfc_supported')"></span>
|
||||
</q-chip>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('nfc_not_supported')"
|
||||
></span>
|
||||
<div v-if="!receive.fiatPaymentReq">
|
||||
<q-chip v-if="hasNfc" outline square color="positive">
|
||||
<q-avatar
|
||||
icon="nfc"
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="$t('nfc_supported')"></span>
|
||||
</q-chip>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('nfc_not_supported')"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(receive.paymentReq)"
|
||||
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@@ -751,6 +876,21 @@
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="text-center" v-text="parse.invoice.description"></h6>
|
||||
<q-input
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.internalMemo"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_pay')"
|
||||
class="q-mb-lg"
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-if="parse.data.internalMemo" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="parse.data.internalMemo = null"
|
||||
class="cursor-pointer" /></template
|
||||
></q-input>
|
||||
<q-list separator bordered dense class="q-mb-md">
|
||||
<q-expansion-item expand-separator icon="info" label="Details">
|
||||
<q-list separator>
|
||||
@@ -944,7 +1084,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-mb-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
@@ -978,9 +1118,39 @@
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
:type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
:maxlength="parse.lnurlpay.commentAllowed"
|
||||
><template
|
||||
v-if="parse.data.internalMemo === null"
|
||||
v-slot:append
|
||||
>
|
||||
<q-icon
|
||||
name="add_comment"
|
||||
@click.stop.prevent="parse.data.internalMemo = ''"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('internal_memo')"></span>
|
||||
</q-tooltip> </template
|
||||
></q-input>
|
||||
<br />
|
||||
<q-input
|
||||
v-if="parse.data.internalMemo !== null"
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.internalMemo"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_pay')"
|
||||
class=""
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="parse.data.internalMemo = null"
|
||||
class="cursor-pointer"
|
||||
/> </template
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</q-input>
|
||||
<q-select
|
||||
<q-btn
|
||||
v-else-if="['status'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
flat
|
||||
dense
|
||||
:label="$q.screen.gt.md ? 'Status' : null"
|
||||
icon="filter_alt"
|
||||
color="grey"
|
||||
class="text-capitalize"
|
||||
>
|
||||
<q-menu anchor="top right" self="top start">
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.success"
|
||||
@click="handleFilterChanged"
|
||||
label="Success Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.pending"
|
||||
@click="handleFilterChanged"
|
||||
label="Pending Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.failed"
|
||||
@click="handleFilterChanged"
|
||||
label="Failed Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.incoming"
|
||||
@click="handleFilterChanged"
|
||||
label="Incoming Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.outgoing"
|
||||
@click="handleFilterChanged"
|
||||
label="Outgoing Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
</q-menu>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('filter_payments')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-select
|
||||
v-else-if="['tag'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
:label="$t('set_password')"
|
||||
v-model="activeUser.setPassword"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
|
||||
</q-toggle>
|
||||
|
||||
<q-input
|
||||
@@ -105,11 +105,12 @@
|
||||
|
||||
<q-input
|
||||
v-model="activeUser.data.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
:label="'Nostr '+ $t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.email"
|
||||
@@ -146,6 +147,14 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.external_id"
|
||||
:label="$t('external_id')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.picture"
|
||||
:label="$t('picture')"
|
||||
|
||||
@@ -15,19 +15,36 @@
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row">
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="deleteAllUserWallets(activeWallet.userId)"
|
||||
:label="$t('delete_all_wallets')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
|
||||
@@ -85,7 +85,8 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
if not admin_settings:
|
||||
raise ValueError("Updated admin settings not found.")
|
||||
update_cached_settings(admin_settings.dict())
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
return {"status": "Success"}
|
||||
|
||||
@@ -93,7 +93,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(
|
||||
async def api_lnurlscan( # noqa: C901
|
||||
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
@@ -124,20 +124,27 @@ async def api_lnurlscan(
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
assert lnurlauth_key.verifying_key
|
||||
if not lnurlauth_key.verifying_key:
|
||||
raise ValueError("LNURL auth key not found for this domain.")
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
check_callback_url(url)
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error:
|
||||
try:
|
||||
r = await client.get(url, timeout=5)
|
||||
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(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={"domain": domain, "message": "failed to get parameters"},
|
||||
)
|
||||
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": "failed to get parameters",
|
||||
},
|
||||
) from exc
|
||||
try:
|
||||
data = json.loads(r.text)
|
||||
except json.decoder.JSONDecodeError as exc:
|
||||
|
||||
@@ -191,12 +191,14 @@ async def api_create_user_api_token(
|
||||
data: ApiTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> ApiTokenResponse:
|
||||
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
|
||||
if not data.expiration_time_minutes > 0:
|
||||
raise ValueError("Expiration time must be in the future.")
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
@@ -223,7 +225,8 @@ async def api_delete_user_api_token(
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
@@ -318,7 +321,7 @@ async def update_pubkey(
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> Optional[User]:
|
||||
if data.user_id != user.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
raise ValueError("Invalid user ID.")
|
||||
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if (
|
||||
@@ -326,7 +329,7 @@ async def update_pubkey(
|
||||
and data.pubkey != user.pubkey
|
||||
and await get_account_by_pubkey(data.pubkey)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
|
||||
raise ValueError("Public key already in use.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
@@ -344,7 +347,8 @@ async def update_password(
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> Optional[User]:
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
assert data.user_id == user.id, "Invalid user ID."
|
||||
if data.user_id != user.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
@@ -353,12 +357,15 @@ async def update_password(
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
assert account, "Account not found."
|
||||
if not account:
|
||||
raise ValueError("Account not found.")
|
||||
|
||||
# old accounts do not have a password
|
||||
if account.password_hash:
|
||||
assert data.password_old, "Missing old password."
|
||||
assert account.verify_password(data.password_old), "Invalid old password."
|
||||
if not data.password_old:
|
||||
raise ValueError("Missing old password.")
|
||||
if not account.verify_password(data.password_old):
|
||||
raise ValueError("Invalid old password.")
|
||||
|
||||
account.username = data.username
|
||||
account.hash_password(data.password)
|
||||
@@ -376,8 +383,10 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
assert data.password == data.password_repeat, "Passwords do not match."
|
||||
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
|
||||
if data.password != data.password_repeat:
|
||||
raise ValueError("Passwords do not match.")
|
||||
if not data.reset_key[:10].startswith("reset_key_"):
|
||||
raise ValueError("This is not a reset key.")
|
||||
|
||||
try:
|
||||
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
||||
@@ -385,12 +394,16 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
except Exception as exc:
|
||||
raise ValueError("Invalid reset key.") from exc
|
||||
|
||||
assert reset_data_json, "Cannot process reset key."
|
||||
if not reset_data_json:
|
||||
raise ValueError("Cannot process reset key.")
|
||||
|
||||
action, user_id, request_time = json.loads(reset_data_json)
|
||||
assert action, "Missing action."
|
||||
assert user_id, "Missing user ID."
|
||||
assert request_time, "Missing reset time."
|
||||
if not action:
|
||||
raise ValueError("Missing action.")
|
||||
if not user_id:
|
||||
raise ValueError("Missing user ID.")
|
||||
if not request_time:
|
||||
raise ValueError("Missing reset time.")
|
||||
|
||||
_validate_auth_timeout(request_time)
|
||||
|
||||
@@ -411,23 +424,13 @@ async def update(
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
if data.username and not is_valid_username(data.username):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||
if data.email != user.email:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Email mismatch.",
|
||||
)
|
||||
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
and await get_account_by_username(data.username)
|
||||
):
|
||||
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)
|
||||
if not account:
|
||||
@@ -435,8 +438,6 @@ async def update(
|
||||
|
||||
if data.username:
|
||||
account.username = data.username
|
||||
if data.email:
|
||||
account.email = data.email
|
||||
if data.extra:
|
||||
account.extra = data.extra
|
||||
|
||||
@@ -569,8 +570,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
||||
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
||||
if provider_class:
|
||||
return provider_class
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||
|
||||
@@ -588,28 +589,40 @@ def _nostr_nip98_event(request: Request) -> dict:
|
||||
event = json.loads(event_json)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
assert event, "Nostr login event cannot be parsed."
|
||||
if not event:
|
||||
raise ValueError("Nostr login event cannot be parsed.")
|
||||
|
||||
if not verify_event(event):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
|
||||
assert event["kind"] == 27_235, "Invalid event kind."
|
||||
if not event["kind"] == 27_235:
|
||||
raise ValueError("Invalid event kind.")
|
||||
|
||||
auth_threshold = settings.auth_credetials_update_threshold
|
||||
assert (
|
||||
abs(time() - event["created_at"]) < auth_threshold
|
||||
), f"More than {auth_threshold} seconds have passed since the event was signed."
|
||||
if not (abs(time() - event["created_at"]) < auth_threshold):
|
||||
raise ValueError(
|
||||
f"More than {auth_threshold} seconds have passed "
|
||||
"since the event was signed."
|
||||
)
|
||||
|
||||
_check_nostr_event_tags(event)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _check_nostr_event_tags(event: dict):
|
||||
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
|
||||
assert method, "Tag 'method' is missing."
|
||||
assert method.upper() == "POST", "Invalid value for tag 'method'."
|
||||
if not method:
|
||||
raise ValueError("Tag 'method' is missing.")
|
||||
if not method.upper() == "POST":
|
||||
raise ValueError("Invalid value for tag 'method'.")
|
||||
|
||||
url = next((v for k, v in event["tags"] if k == "u"), None)
|
||||
|
||||
assert url, "Tag 'u' for URL is missing."
|
||||
if not url:
|
||||
raise ValueError("Tag 'u' for URL is missing.")
|
||||
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
|
||||
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
|
||||
|
||||
return event
|
||||
if url not in accepted_urls:
|
||||
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
|
||||
|
||||
|
||||
def _validate_auth_timeout(auth_time: Optional[int] = 0):
|
||||
|
||||
@@ -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}'.",
|
||||
)
|
||||
@@ -170,64 +170,52 @@ async def api_enable_extension(
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
try:
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
ext = await get_installed_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' is not installed."
|
||||
assert ext.active, f"Extension '{ext_id}' is not activated."
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
|
||||
if user.admin or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
if user.admin or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
|
||||
if user_ext.is_paid:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Paid extension '{ext_id}' enabled."
|
||||
)
|
||||
|
||||
assert (
|
||||
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
|
||||
), f"Extension '{ext_id}' is missing payment wallet."
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
|
||||
if not payment_status.paid:
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
if user_ext.is_paid:
|
||||
user_ext.active = True
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except HTTPException as exc:
|
||||
raise exc from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
|
||||
raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||
)
|
||||
|
||||
if not payment_status.paid:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to enable '{ext_id}' "),
|
||||
) from exc
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
user_ext.active = True
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/disable")
|
||||
@@ -255,7 +243,8 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
logger.info(f"Activating extension: '{ext_id}'.")
|
||||
|
||||
ext = await get_valid_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
||||
|
||||
await activate_extension(ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||
@@ -274,7 +263,8 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||
|
||||
ext = await get_valid_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
||||
|
||||
await deactivate_extension(ext_id)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||
@@ -354,40 +344,35 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
|
||||
async def get_pay_to_install_invoice(
|
||||
ext_id: str, data: CreateExtension
|
||||
) -> ReleasePaymentInfo:
|
||||
try:
|
||||
assert (
|
||||
ext_id == data.ext_id
|
||||
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||
assert data.cost_sats, "A non-zero amount must be specified."
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
if ext_id != data.ext_id:
|
||||
raise ValueError(
|
||||
f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||
)
|
||||
assert release, "Release not found."
|
||||
assert release.pay_link, "Pay link not found for release."
|
||||
if not data.cost_sats:
|
||||
raise ValueError("A non-zero amount must be specified.")
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
if not release:
|
||||
raise ValueError("Release not found.")
|
||||
if not release.pay_link:
|
||||
raise ValueError("Pay link not found for release.")
|
||||
|
||||
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
||||
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
||||
|
||||
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
if not (payment_info and payment_info.payment_request):
|
||||
raise ValueError("Cannot request invoice.")
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
|
||||
assert invoice.amount_msat is not None, "Invoic amount is missing."
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
assert (
|
||||
invoice_amount == data.cost_sats
|
||||
), f"Wrong invoice amount: {invoice_amount}."
|
||||
assert (
|
||||
payment_info.payment_hash == invoice.payment_hash
|
||||
), "Wrong invoice payment hash."
|
||||
if invoice.amount_msat is None:
|
||||
raise ValueError("Invoic amount is missing.")
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
if invoice_amount != data.cost_sats:
|
||||
raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
|
||||
if payment_info.payment_hash != invoice.payment_hash:
|
||||
raise ValueError("Wrong invoice payment hash.")
|
||||
|
||||
return payment_info
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
|
||||
) from exc
|
||||
return payment_info
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/invoice/enable")
|
||||
|
||||
@@ -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 typing import Annotated, List, Optional, Union
|
||||
from typing import Annotated, Optional, Union
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -70,7 +70,7 @@ async def robots():
|
||||
|
||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
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")
|
||||
async def service_worker(request: Request):
|
||||
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)
|
||||
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(
|
||||
"payments/index.html",
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
@@ -91,7 +91,7 @@ async def api_get_info(
|
||||
@node_router.get("/channels")
|
||||
async def api_get_channels(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
) -> Optional[list[NodeChannel]]:
|
||||
return await node.get_channels()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async def api_delete_channel(
|
||||
output_index: Optional[int],
|
||||
force: bool = False,
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
) -> Optional[list[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
(
|
||||
@@ -170,7 +170,7 @@ async def api_get_invoices(
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||
|
||||
+121
-136
@@ -2,7 +2,7 @@ import json
|
||||
import ssl
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -34,17 +34,11 @@ from lnbits.core.models import (
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentWalletStats,
|
||||
Wallet,
|
||||
)
|
||||
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.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
@@ -66,11 +60,14 @@ from ..crud import (
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
update_payment_extra,
|
||||
)
|
||||
from ..services import (
|
||||
create_invoice,
|
||||
create_payment_request,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
)
|
||||
|
||||
@@ -82,7 +79,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
response_model=list[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
@@ -101,7 +98,7 @@ async def api_payments(
|
||||
@payment_router.get(
|
||||
"/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
response_model=list[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
@@ -116,59 +113,61 @@ async def api_payments_history(
|
||||
@payment_router.get(
|
||||
"/stats/count",
|
||||
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),
|
||||
)
|
||||
async def api_payments_counting_stats(
|
||||
count_by: PaymentCountField = Query("tag"),
|
||||
filters: Filters[PaymentFilters] = 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_payment_count_stats(count_by, filters)
|
||||
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/wallets",
|
||||
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),
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = 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_wallets_stats(filters)
|
||||
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/daily",
|
||||
name="Get payments history per day",
|
||||
response_model=List[PaymentDailyStats],
|
||||
response_model=list[PaymentDailyStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_daily_stats(
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
if not user.admin:
|
||||
exc = HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Missing wallet id.",
|
||||
)
|
||||
wallet_filter = next(
|
||||
(f for f in filters.filters if f.field == "wallet_id"), None
|
||||
)
|
||||
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)
|
||||
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_daily_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
@@ -194,69 +193,6 @@ async def api_payments_paginated(
|
||||
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(
|
||||
"/all/paginated",
|
||||
name="Payment List",
|
||||
@@ -304,6 +240,7 @@ async def api_payments_create(
|
||||
invoice_data: CreateInvoice,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Payment:
|
||||
wallet_id = wallet.wallet.id
|
||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||
if not invoice_data.bolt11:
|
||||
raise HTTPException(
|
||||
@@ -311,21 +248,53 @@ async def api_payments_create(
|
||||
detail="Missing BOLT11 invoice",
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
wallet_id=wallet_id,
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
)
|
||||
return payment
|
||||
|
||||
elif not invoice_data.out:
|
||||
# invoice key
|
||||
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||
else:
|
||||
if invoice_data.out:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
# If the payment is not outgoing, we can create a new invoice.
|
||||
return await create_payment_request(wallet_id, invoice_data)
|
||||
|
||||
|
||||
@payment_router.patch(
|
||||
"/extra/{payment_hash}", description="Update extra data for a payment"
|
||||
)
|
||||
async def api_payments_update_extra(
|
||||
payment_hash: str,
|
||||
extra: dict,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Payment:
|
||||
payment = await get_standalone_payment(payment_hash, wallet_id=wallet.wallet.id)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
if not isinstance(extra, dict):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Extra data must be a dictionary.",
|
||||
)
|
||||
if payment.extra is None:
|
||||
payment.extra = {}
|
||||
|
||||
for key, value in extra.items():
|
||||
if key in payment.extra:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Key '{key}' already exists in extra data.",
|
||||
)
|
||||
payment.extra[key] = value
|
||||
|
||||
return await update_payment_extra(payment)
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
@@ -342,23 +311,36 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
def _validate_lnurl_response(
|
||||
params: dict, domain: str, amount_msat: int
|
||||
) -> bolt11.Invoice:
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
)
|
||||
return invoice
|
||||
|
||||
|
||||
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
check_callback_url(data.callback)
|
||||
r = await client.get(
|
||||
r: httpx.Response = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
@@ -372,29 +354,24 @@ async def api_payments_pay_lnurl(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) from exc
|
||||
return json.loads(r.text)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
check_callback_url(data.callback)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
)
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
params = await _fetch_lnurl_params(data, amount_msat, domain)
|
||||
_validate_lnurl_response(params, domain, amount_msat)
|
||||
|
||||
extra = {}
|
||||
if params.get("successAction"):
|
||||
@@ -404,7 +381,12 @@ async def api_payments_pay_lnurl(
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
if data.internal_memo is not None:
|
||||
if len(data.internal_memo) > 512:
|
||||
raise ValueError("Internal memo must be 512 characters or less.")
|
||||
extra["internal_memo"] = data.internal_memo
|
||||
if data.description is None:
|
||||
raise ValueError("Description is required")
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
@@ -435,6 +417,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}
|
||||
|
||||
if payment.failed:
|
||||
return {"paid": False, "status": "failed", "details": payment}
|
||||
|
||||
try:
|
||||
status = await payment.check_status()
|
||||
except Exception:
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import json
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import shortuuid
|
||||
@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra,
|
||||
)
|
||||
account.validate_fields()
|
||||
@@ -132,6 +133,7 @@ async def api_update_user(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra or UserExtra(),
|
||||
)
|
||||
await update_user_account(account)
|
||||
@@ -148,7 +150,7 @@ async def api_update_user(
|
||||
async def api_users_delete_user(
|
||||
user_id: str, user: User = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id)
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
if len(wallets) > 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -185,7 +187,8 @@ async def api_users_reset_password(user_id: str) -> str:
|
||||
reset_data = ["reset", user_id, int(time.time())]
|
||||
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
|
||||
reset_key = encrypt_internal_message(reset_data_json)
|
||||
assert reset_key, "Cannot generate reset key."
|
||||
if not reset_key:
|
||||
raise ValueError("Cannot generate reset key.")
|
||||
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
|
||||
return f"reset_key_{reset_key_b64}"
|
||||
|
||||
@@ -214,7 +217,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
||||
|
||||
|
||||
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
||||
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
|
||||
return await get_wallets(user_id)
|
||||
|
||||
|
||||
@@ -257,6 +260,28 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
|
||||
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(
|
||||
"/user/{user_id}/wallet/{wallet}",
|
||||
name="Delete wallet by id",
|
||||
|
||||
@@ -9,13 +9,18 @@ from fastapi import (
|
||||
HTTPException,
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||
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 (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
||||
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}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -74,6 +99,7 @@ async def api_update_wallet(
|
||||
icon: Optional[str] = Body(None),
|
||||
color: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
pinned: Optional[bool] = Body(None),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(key_info.wallet.id)
|
||||
@@ -82,6 +108,7 @@ async def api_update_wallet(
|
||||
wallet.name = name or wallet.name
|
||||
wallet.extra.icon = icon or wallet.extra.icon
|
||||
wallet.extra.color = color or wallet.extra.color
|
||||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
+4
-4
@@ -252,7 +252,7 @@ class Connection(Compat):
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
parsed_values,
|
||||
)
|
||||
row = result.mappings().first()
|
||||
@@ -597,7 +597,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields = ", ".join([f'"{key}"' for key in keys])
|
||||
values = ", ".join(placeholders)
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" # noqa: S608
|
||||
|
||||
|
||||
def update_query(
|
||||
@@ -615,7 +615,7 @@ def update_query(
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields.append(f'"{field}" = {placeholder}')
|
||||
query = ", ".join(fields)
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
|
||||
|
||||
|
||||
def model_to_dict(model: BaseModel) -> dict:
|
||||
@@ -658,7 +658,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||
return dict_to_model(_subdict, model)
|
||||
|
||||
|
||||
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
||||
"""
|
||||
Convert a dictionary with JSON-encoded nested models to a Pydantic model
|
||||
:param _dict: Dictionary from database
|
||||
|
||||
+16
-3
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
from typing import Annotated, Literal, Optional, Union
|
||||
|
||||
import jwt
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
@@ -28,7 +28,7 @@ from lnbits.core.models import (
|
||||
WalletTypeInfo,
|
||||
)
|
||||
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
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
@@ -223,7 +223,7 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
|
||||
return user
|
||||
|
||||
|
||||
def parse_filters(model: Type[TFilterModel]):
|
||||
def parse_filters(model: type[TFilterModel]):
|
||||
"""
|
||||
Parses the query params as filters.
|
||||
: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.")
|
||||
if not endpoint.supports_method(method):
|
||||
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]
|
||||
|
||||
+10
-4
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from loguru import logger
|
||||
from shortuuid import uuid
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -25,6 +26,10 @@ class InvoiceError(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
class UnsupportedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
@@ -64,17 +69,18 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
def register_exception_handlers(app: FastAPI): # noqa: C901
|
||||
"""Register exception handlers for the FastAPI app"""
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
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(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
content={"detail": f"Unexpected error! ID: {exception_id}"},
|
||||
)
|
||||
|
||||
@app.exception_handler(AssertionError)
|
||||
@@ -145,7 +151,7 @@ def register_exception_handlers(app: FastAPI):
|
||||
status_code = HTTPStatus.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
|
||||
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)]
|
||||
)
|
||||
+44
-11
@@ -3,7 +3,7 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Type
|
||||
from typing import Any, Optional
|
||||
from urllib import request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -11,12 +11,12 @@ import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi.routing import APIRoute
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
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:
|
||||
base = g().base_url if external else ""
|
||||
base = f"http://{settings.host}:{settings.port}" if external else ""
|
||||
url_params = "?"
|
||||
for key, value in params.items():
|
||||
url_params += f"{key}={value}&"
|
||||
@@ -76,6 +76,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
"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,
|
||||
@@ -151,7 +153,7 @@ def get_current_extension_name() -> str:
|
||||
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
|
||||
parse_filters (see example)
|
||||
@@ -195,6 +197,14 @@ def is_valid_username(username: str) -> bool:
|
||||
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:
|
||||
if len(pubkey) != 64:
|
||||
return False
|
||||
@@ -279,16 +289,28 @@ def is_lnbits_version_ok(
|
||||
|
||||
|
||||
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:
|
||||
if re.match(rule, netloc) is None:
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
||||
)
|
||||
try:
|
||||
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
|
||||
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):
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
def download_url(url: str, save_path: Path):
|
||||
if not url.startswith(("http:", "https:")):
|
||||
raise ValueError(f"Invalid URL: {url}. Must start with 'http' or 'https'.")
|
||||
|
||||
with request.urlopen(url, timeout=60) as dl_file: # noqa: S310
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
|
||||
@@ -343,3 +365,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
|
||||
# Prevent filename with subdirectories
|
||||
file_path = image_folder / filename.split("/")[-1]
|
||||
return file_path.resolve()
|
||||
|
||||
|
||||
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
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__ = [
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
"decode",
|
||||
"encode",
|
||||
"handle",
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
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.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
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]:
|
||||
"""
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||
@@ -131,7 +131,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
assert response
|
||||
return response
|
||||
finally:
|
||||
if request_details:
|
||||
|
||||
@@ -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()
|
||||
+113
-8
@@ -201,10 +201,11 @@ class InstalledExtensionsSettings(LNbitsSettings):
|
||||
if r.find_in_conflict(ext_redirect_paths)
|
||||
}
|
||||
|
||||
assert len(existing_redirects) == 0, (
|
||||
f"Cannot redirect for extension '{ext_id}'."
|
||||
f" Already mapped by {existing_redirects}."
|
||||
)
|
||||
if len(existing_redirects) != 0:
|
||||
raise ValueError(
|
||||
f"Cannot redirect for extension '{ext_id}'."
|
||||
f" Already mapped by {existing_redirects}."
|
||||
)
|
||||
|
||||
self._remove_extension_redirects(ext_id)
|
||||
self.lnbits_extensions_redirects += ext_redirect_paths
|
||||
@@ -255,7 +256,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
@@ -305,7 +306,7 @@ class FeeSettings(LNbitsSettings):
|
||||
return 0
|
||||
reserve_min = self.lnbits_reserve_fee_min
|
||||
reserve_percent = self.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
return max(int(reserve_min), int(abs(amount_msat) * reserve_percent / 100.0))
|
||||
|
||||
|
||||
class ExchangeProvidersSettings(LNbitsSettings):
|
||||
@@ -381,7 +382,7 @@ class SecuritySettings(LNbitsSettings):
|
||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||
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)
|
||||
@@ -448,6 +449,18 @@ class ClicheFundingSource(LNbitsSettings):
|
||||
cliche_endpoint: str | None = Field(default=None)
|
||||
|
||||
|
||||
class CLNRestFundingSource(LNbitsSettings):
|
||||
clnrest_url: str | None = Field(default=None)
|
||||
clnrest_ca: str | None = Field(default=None)
|
||||
clnrest_cert: str | None = Field(default=None)
|
||||
clnrest_readonly_rune: str | None = Field(default=None)
|
||||
clnrest_invoice_rune: str | None = Field(default=None)
|
||||
clnrest_pay_rune: str | None = Field(default=None)
|
||||
clnrest_renepay_rune: str | None = Field(default=None)
|
||||
clnrest_last_pay_index: str | None = Field(default=None)
|
||||
clnrest_nodeid: str | None = Field(default=None)
|
||||
|
||||
|
||||
class CoreLightningFundingSource(LNbitsSettings):
|
||||
corelightning_rpc: str | None = Field(default=None)
|
||||
corelightning_pay_command: str = Field(default="pay")
|
||||
@@ -549,11 +562,54 @@ class BreezSdkFundingSource(LNbitsSettings):
|
||||
breez_use_trampoline: bool = Field(default=True)
|
||||
|
||||
|
||||
class BreezLiquidSdkFundingSource(LNbitsSettings):
|
||||
breez_liquid_api_key: str | None = Field(default=None)
|
||||
breez_liquid_seed: str | None = Field(default=None)
|
||||
breez_liquid_fee_offset_sat: int = Field(default=50)
|
||||
|
||||
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: str | None = Field(default=None)
|
||||
boltz_client_wallet: str | None = Field(default="lnbits")
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
|
||||
|
||||
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):
|
||||
@@ -564,6 +620,7 @@ class FundingSourcesSettings(
|
||||
FakeWalletFundingSource,
|
||||
LNbitsFundingSource,
|
||||
ClicheFundingSource,
|
||||
CLNRestFundingSource,
|
||||
CoreLightningFundingSource,
|
||||
CoreLightningRestFundingSource,
|
||||
EclairFundingSource,
|
||||
@@ -580,6 +637,8 @@ class FundingSourcesSettings(
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
StrikeFundingSource,
|
||||
BreezLiquidSdkFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
@@ -587,6 +646,40 @@ class FundingSourcesSettings(
|
||||
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):
|
||||
lnbits_webpush_pubkey: str | None = Field(default=None)
|
||||
lnbits_webpush_privkey: str | None = Field(default=None)
|
||||
@@ -604,7 +697,7 @@ class NodeUISettings(LNbitsSettings):
|
||||
|
||||
class AuthMethods(Enum):
|
||||
user_id_only = "user-id-only"
|
||||
username_and_password = "username-password"
|
||||
username_and_password = "username-password" # noqa: S105
|
||||
nostr_auth_nip98 = "nostr-auth-nip98"
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
@@ -659,6 +752,8 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_discovery_url: str = Field(default="")
|
||||
keycloak_client_id: 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):
|
||||
@@ -757,6 +852,7 @@ class EditableSettings(
|
||||
SecuritySettings,
|
||||
NotificationsSettings,
|
||||
FundingSourcesSettings,
|
||||
FiatProvidersSettings,
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
@@ -849,6 +945,8 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
"BreezLiquidSdkWallet",
|
||||
"CLNRestWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
@@ -863,6 +961,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
"StrikeWallet",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -950,6 +1049,12 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
def is_installed_extension_id(self, ext_id: str) -> bool:
|
||||
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):
|
||||
super_user: str
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
+154
-571
@@ -1,617 +1,200 @@
|
||||
body[data-theme=classic].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=classic].neon-border .q-card,
|
||||
body[data-theme=classic].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=classic].neon-border .q-date,
|
||||
body[data-theme=classic].neon-border .q-date--dark {
|
||||
border: 2px solid #673ab7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=classic].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=bitcoin].neon-border .q-card,
|
||||
body[data-theme=bitcoin].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=bitcoin].neon-border .q-date,
|
||||
body[data-theme=bitcoin].neon-border .q-date--dark {
|
||||
border: 2px solid #ea611d;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme=classic] .q-drawer--dark,
|
||||
body[data-theme=classic].body--dark,
|
||||
[data-theme=classic] .q-menu--dark {
|
||||
background: #1f2234 !important;
|
||||
body[data-theme=freedom].neon-border .q-card,
|
||||
body[data-theme=freedom].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=freedom].neon-border .q-date,
|
||||
body[data-theme=freedom].neon-border .q-date--dark {
|
||||
border: 2px solid #e22156;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='classic'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=classic].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=cyber].neon-border .q-card,
|
||||
body[data-theme=cyber].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=cyber].neon-border .q-date,
|
||||
body[data-theme=cyber].neon-border .q-date--dark {
|
||||
border: 2px solid #7cb342;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme=classic] .q-card--dark,
|
||||
[data-theme=classic] .q-stepper--dark {
|
||||
body[data-theme=mint].neon-border .q-card,
|
||||
body[data-theme=mint].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=mint].neon-border .q-date,
|
||||
body[data-theme=mint].neon-border .q-date--dark {
|
||||
border: 2px solid #3ab77d;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=autumn].neon-border .q-card,
|
||||
body[data-theme=autumn].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=autumn].neon-border .q-date,
|
||||
body[data-theme=autumn].neon-border .q-date--dark {
|
||||
border: 2px solid #b7763a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].neon-border .q-card,
|
||||
body[data-theme=flamingo].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=flamingo].neon-border .q-date,
|
||||
body[data-theme=flamingo].neon-border .q-date--dark {
|
||||
border: 2px solid #ff00ff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].neon-border .q-card,
|
||||
body[data-theme=monochrome].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=monochrome].neon-border .q-date,
|
||||
body[data-theme=monochrome].neon-border .q-date--dark {
|
||||
border: 2px solid #494949;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.hard-border .q-card,
|
||||
body.hard-border .q-card.q-card--dark,
|
||||
body.hard-border .q-date,
|
||||
body.hard-border .q-date--dark {
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.2784313725);
|
||||
border: none;
|
||||
}
|
||||
|
||||
body.retro-border .q-card,
|
||||
body.retro-border .q-card.q-card--dark,
|
||||
body.retro-border .q-date,
|
||||
body.retro-border .q-date--dark {
|
||||
border: none;
|
||||
box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.no-border .q-card,
|
||||
body.no-border .q-card.q-card--dark,
|
||||
body.no-border .q-date,
|
||||
body.no-border .q-date--dark {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=classic] {
|
||||
--q-primary: #673ab7;
|
||||
--q-secondary: #9c27b0;
|
||||
--q-dark-page: #1f2234;
|
||||
}
|
||||
body[data-theme=classic] [data-theme=classic] .q-card--dark,
|
||||
body[data-theme=classic] [data-theme=classic] .q-stepper--dark {
|
||||
background: #333646 !important;
|
||||
}
|
||||
|
||||
body[data-theme=classic].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=bitcoin] {
|
||||
--q-primary: #ea611d;
|
||||
--q-secondary: #e56f35;
|
||||
--q-dark-page: #2d293b;
|
||||
}
|
||||
|
||||
body[data-theme=classic].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=classic] .bg-primary {
|
||||
background: #673ab7 !important;
|
||||
}
|
||||
[data-theme=classic] .text-primary {
|
||||
color: #673ab7 !important;
|
||||
}
|
||||
[data-theme=classic] .bg-secondary {
|
||||
background: #9c27b0 !important;
|
||||
}
|
||||
[data-theme=classic] .text-secondary {
|
||||
color: #9c27b0 !important;
|
||||
}
|
||||
[data-theme=classic] .bg-dark {
|
||||
background: #1f2234 !important;
|
||||
}
|
||||
[data-theme=classic] .text-dark {
|
||||
color: #1f2234 !important;
|
||||
}
|
||||
[data-theme=classic] .bg-info {
|
||||
background: #333646 !important;
|
||||
}
|
||||
[data-theme=classic] .text-info {
|
||||
color: #333646 !important;
|
||||
}
|
||||
[data-theme=classic] .bg-marginal-bg {
|
||||
background: #1f2234 !important;
|
||||
}
|
||||
[data-theme=classic] .text-marginal-bg {
|
||||
color: #1f2234 !important;
|
||||
}
|
||||
[data-theme=classic] .bg-marginal-text {
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-theme=classic] .text-marginal-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=bitcoin] .q-drawer--dark,
|
||||
body[data-theme=bitcoin].body--dark,
|
||||
[data-theme=bitcoin] .q-menu--dark {
|
||||
background: #2d293b !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='bitcoin'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=bitcoin] .q-card--dark,
|
||||
[data-theme=bitcoin] .q-stepper--dark {
|
||||
body[data-theme=bitcoin] [data-theme=bitcoin] .q-card--dark,
|
||||
body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
|
||||
background: #333646 !important;
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=freedom] {
|
||||
--q-primary: #e22156;
|
||||
--q-secondary: #b91a45;
|
||||
--q-dark-page: #0a0a0a;
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=bitcoin] .bg-primary {
|
||||
background: #ea611d !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-primary {
|
||||
color: #ea611d !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-secondary {
|
||||
background: #e56f35 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-secondary {
|
||||
color: #e56f35 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-dark {
|
||||
background: #2d293b !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-dark {
|
||||
color: #2d293b !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-info {
|
||||
background: #333646 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-info {
|
||||
color: #333646 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-marginal-bg {
|
||||
background: #000000 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-marginal-bg {
|
||||
color: #000000 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-marginal-text {
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-marginal-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=freedom] .q-drawer--dark,
|
||||
body[data-theme=freedom].body--dark,
|
||||
[data-theme=freedom] .q-menu--dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='freedom'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=freedom] .q-card--dark,
|
||||
[data-theme=freedom] .q-stepper--dark {
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=cyber] {
|
||||
--q-primary: #7cb342;
|
||||
--q-secondary: #558b2f;
|
||||
--q-dark-page: #0a0a0a;
|
||||
}
|
||||
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=freedom] .bg-primary {
|
||||
background: #e22156 !important;
|
||||
}
|
||||
[data-theme=freedom] .text-primary {
|
||||
color: #e22156 !important;
|
||||
}
|
||||
[data-theme=freedom] .bg-secondary {
|
||||
background: #b91a45 !important;
|
||||
}
|
||||
[data-theme=freedom] .text-secondary {
|
||||
color: #b91a45 !important;
|
||||
}
|
||||
[data-theme=freedom] .bg-dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
[data-theme=freedom] .text-dark {
|
||||
color: #0a0a0a !important;
|
||||
}
|
||||
[data-theme=freedom] .bg-info {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
[data-theme=freedom] .text-info {
|
||||
color: #1b1b1b !important;
|
||||
}
|
||||
[data-theme=freedom] .bg-marginal-bg {
|
||||
background: #2d293b !important;
|
||||
}
|
||||
[data-theme=freedom] .text-marginal-bg {
|
||||
color: #2d293b !important;
|
||||
}
|
||||
[data-theme=freedom] .bg-marginal-text {
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-theme=freedom] .text-marginal-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=cyber] .q-drawer--dark,
|
||||
body[data-theme=cyber].body--dark,
|
||||
[data-theme=cyber] .q-menu--dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='cyber'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=cyber] .q-card--dark,
|
||||
[data-theme=cyber] .q-stepper--dark {
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=mint] {
|
||||
--q-primary: #3ab77d;
|
||||
--q-secondary: #27b065;
|
||||
--q-dark-page: #1f342b;
|
||||
}
|
||||
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=cyber] .bg-primary {
|
||||
background: #7cb342 !important;
|
||||
}
|
||||
[data-theme=cyber] .text-primary {
|
||||
color: #7cb342 !important;
|
||||
}
|
||||
[data-theme=cyber] .bg-secondary {
|
||||
background: #558b2f !important;
|
||||
}
|
||||
[data-theme=cyber] .text-secondary {
|
||||
color: #558b2f !important;
|
||||
}
|
||||
[data-theme=cyber] .bg-dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
[data-theme=cyber] .text-dark {
|
||||
color: #0a0a0a !important;
|
||||
}
|
||||
[data-theme=cyber] .bg-info {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
[data-theme=cyber] .text-info {
|
||||
color: #1b1b1b !important;
|
||||
}
|
||||
[data-theme=cyber] .bg-marginal-bg {
|
||||
background: #2d293b !important;
|
||||
}
|
||||
[data-theme=cyber] .text-marginal-bg {
|
||||
color: #2d293b !important;
|
||||
}
|
||||
[data-theme=cyber] .bg-marginal-text {
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-theme=cyber] .text-marginal-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body[data-theme=mint].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=mint].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=mint] .q-drawer--dark,
|
||||
body[data-theme=mint].body--dark,
|
||||
[data-theme=mint] .q-menu--dark {
|
||||
background: #1f342b !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='mint'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=mint].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=mint] .q-card--dark,
|
||||
[data-theme=mint] .q-stepper--dark {
|
||||
body[data-theme=mint] [data-theme=mint] .q-card--dark,
|
||||
body[data-theme=mint] [data-theme=mint] .q-stepper--dark {
|
||||
background: #334642 !important;
|
||||
}
|
||||
|
||||
body[data-theme=mint].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=autumn] {
|
||||
--q-primary: #b7763a;
|
||||
--q-secondary: #b07927;
|
||||
--q-dark-page: #34291f;
|
||||
}
|
||||
|
||||
body[data-theme=mint].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=mint] .bg-primary {
|
||||
background: #3ab77d !important;
|
||||
}
|
||||
[data-theme=mint] .text-primary {
|
||||
color: #3ab77d !important;
|
||||
}
|
||||
[data-theme=mint] .bg-secondary {
|
||||
background: #27b065 !important;
|
||||
}
|
||||
[data-theme=mint] .text-secondary {
|
||||
color: #27b065 !important;
|
||||
}
|
||||
[data-theme=mint] .bg-dark {
|
||||
background: #1f342b !important;
|
||||
}
|
||||
[data-theme=mint] .text-dark {
|
||||
color: #1f342b !important;
|
||||
}
|
||||
[data-theme=mint] .bg-info {
|
||||
background: #334642 !important;
|
||||
}
|
||||
[data-theme=mint] .text-info {
|
||||
color: #334642 !important;
|
||||
}
|
||||
[data-theme=mint] .bg-marginal-bg {
|
||||
background: #1f342b !important;
|
||||
}
|
||||
[data-theme=mint] .text-marginal-bg {
|
||||
color: #1f342b !important;
|
||||
}
|
||||
[data-theme=mint] .bg-marginal-text {
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-theme=mint] .text-marginal-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
body[data-theme=autumn].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=autumn].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=autumn] .q-drawer--dark,
|
||||
body[data-theme=autumn].body--dark,
|
||||
[data-theme=autumn] .q-menu--dark {
|
||||
background: #34291f !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='autumn'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=autumn].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=autumn] .q-card--dark,
|
||||
[data-theme=autumn] .q-stepper--dark {
|
||||
body[data-theme=autumn] [data-theme=autumn] .q-card--dark,
|
||||
body[data-theme=autumn] [data-theme=autumn] .q-stepper--dark {
|
||||
background: #463f33 !important;
|
||||
}
|
||||
|
||||
body[data-theme=autumn].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=flamingo] {
|
||||
--q-primary: #ff00ff;
|
||||
--q-secondary: #fda3fd;
|
||||
--q-dark-page: #2f032f;
|
||||
}
|
||||
|
||||
body[data-theme=autumn].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=autumn] .bg-primary {
|
||||
background: #b7763a !important;
|
||||
}
|
||||
[data-theme=autumn] .text-primary {
|
||||
color: #b7763a !important;
|
||||
}
|
||||
[data-theme=autumn] .bg-secondary {
|
||||
background: #b07927 !important;
|
||||
}
|
||||
[data-theme=autumn] .text-secondary {
|
||||
color: #b07927 !important;
|
||||
}
|
||||
[data-theme=autumn] .bg-dark {
|
||||
background: #34291f !important;
|
||||
}
|
||||
[data-theme=autumn] .text-dark {
|
||||
color: #34291f !important;
|
||||
}
|
||||
[data-theme=autumn] .bg-info {
|
||||
background: #463f33 !important;
|
||||
}
|
||||
[data-theme=autumn] .text-info {
|
||||
color: #463f33 !important;
|
||||
}
|
||||
[data-theme=autumn] .bg-marginal-bg {
|
||||
background: #342a1f !important;
|
||||
}
|
||||
[data-theme=autumn] .text-marginal-bg {
|
||||
color: #342a1f !important;
|
||||
}
|
||||
[data-theme=autumn] .bg-marginal-text {
|
||||
background: rgb(255, 255, 255) !important;
|
||||
}
|
||||
[data-theme=autumn] .text-marginal-text {
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .q-drawer--dark,
|
||||
body[data-theme=flamingo].body--dark,
|
||||
[data-theme=flamingo] .q-menu--dark {
|
||||
background: #2f032f !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='flamingo'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=flamingo].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .q-card--dark,
|
||||
[data-theme=flamingo] .q-stepper--dark {
|
||||
body[data-theme=flamingo] [data-theme=flamingo] .q-card--dark,
|
||||
body[data-theme=flamingo] [data-theme=flamingo] .q-stepper--dark {
|
||||
background: #bc23bc !important;
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=monochrome] {
|
||||
--q-primary: #494949;
|
||||
--q-secondary: #6b6b6b;
|
||||
--q-dark-page: #000;
|
||||
}
|
||||
|
||||
body[data-theme=flamingo].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .bg-primary {
|
||||
background: #ff00ff !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-primary {
|
||||
color: #ff00ff !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-secondary {
|
||||
background: #fda3fd !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-secondary {
|
||||
color: #fda3fd !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-dark {
|
||||
background: #2f032f !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-dark {
|
||||
color: #2f032f !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-info {
|
||||
background: #bc23bc !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-info {
|
||||
color: #bc23bc !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-marginal-bg {
|
||||
background: #311231 !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-marginal-bg {
|
||||
color: #311231 !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-marginal-text {
|
||||
background: rgb(255, 255, 255) !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-marginal-text {
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=monochrome] .q-drawer--dark,
|
||||
body[data-theme=monochrome].body--dark,
|
||||
[data-theme=monochrome] .q-menu--dark {
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='monochrome'].body--dark {
|
||||
background: scale-color($color, $lightness: -60%);
|
||||
}
|
||||
*/
|
||||
body[data-theme=monochrome].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
[data-theme=monochrome] .q-card--dark,
|
||||
[data-theme=monochrome] .q-stepper--dark {
|
||||
body[data-theme=monochrome] [data-theme=monochrome] .q-card--dark,
|
||||
body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
|
||||
background: rgb(39, 39, 39) !important;
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body.gradient-bg {
|
||||
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body[data-theme=monochrome].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body.bg-image::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(8px);
|
||||
background-image: var(--background);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
[data-theme=monochrome] .bg-primary {
|
||||
background: #494949 !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-primary {
|
||||
color: #494949 !important;
|
||||
}
|
||||
[data-theme=monochrome] .bg-secondary {
|
||||
background: #6b6b6b !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-secondary {
|
||||
color: #6b6b6b !important;
|
||||
}
|
||||
[data-theme=monochrome] .bg-dark {
|
||||
background: #000 !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-dark {
|
||||
color: #000 !important;
|
||||
}
|
||||
[data-theme=monochrome] .bg-info {
|
||||
background: rgb(39, 39, 39) !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-info {
|
||||
color: rgb(39, 39, 39) !important;
|
||||
}
|
||||
[data-theme=monochrome] .bg-marginal-bg {
|
||||
background: #000 !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-marginal-bg {
|
||||
color: #000 !important;
|
||||
}
|
||||
[data-theme=monochrome] .bg-marginal-text {
|
||||
background: rgb(255, 255, 255) !important;
|
||||
}
|
||||
[data-theme=monochrome] .text-marginal-text {
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
[data-theme=freedom] .q-drawer--dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme=freedom] .q-header {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme=cyber] .q-drawer--dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme=cyber] .q-header {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme=salvador] .q-drawer--dark {
|
||||
background: #242424 !important;
|
||||
}
|
||||
|
||||
[data-theme=salvador] .q-header {
|
||||
background: #0f47af !important;
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
body.body--dark .q-header,
|
||||
body.body--dark .q-drawer {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
|
||||
@@ -338,7 +338,7 @@ window.localisation.br = {
|
||||
node_balance: 'Saldo do Nó: {balance} sats',
|
||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percentual: {percent} %',
|
||||
node_managment: 'Gerenciamento de Nós',
|
||||
node_management: 'Gerenciamento de Nós',
|
||||
node_management_not_supported:
|
||||
'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
|
||||
toggle_node_ui: 'Interface do Nó',
|
||||
|
||||
@@ -320,7 +320,7 @@ window.localisation.cn = {
|
||||
node_balance: '节点余额:{balance} sats',
|
||||
lnbits_balance: 'LNbits 余额:{balance} sats',
|
||||
funding_reserve_percent: '保留百分比: {percent} %',
|
||||
node_managment: '节点管理',
|
||||
node_management: '节点管理',
|
||||
node_management_not_supported: '活动资金来源不支持节点管理',
|
||||
toggle_node_ui: '节点用户界面',
|
||||
toggle_public_node_ui: '公共节点用户界面',
|
||||
|
||||
@@ -332,7 +332,7 @@ window.localisation.cs = {
|
||||
node_balance: 'Stav uzlu: {balance} sats',
|
||||
lnbits_balance: 'Zůstatek LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezervovat procento: {percent} %',
|
||||
node_managment: 'Správa uzlů',
|
||||
node_management: 'Správa uzlů',
|
||||
node_management_not_supported:
|
||||
'Správa uzlů není podporována aktivním zdrojem financování',
|
||||
toggle_node_ui: 'Uživatelské rozhraní uzlu',
|
||||
|
||||
@@ -342,7 +342,7 @@ window.localisation.de = {
|
||||
node_balance: 'Kontostand: {balance} Sats',
|
||||
lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
|
||||
funding_reserve_percent: 'Reservieren Prozent: {percent} %',
|
||||
node_managment: 'Knotenverwaltung',
|
||||
node_management: 'Knotenverwaltung',
|
||||
node_management_not_supported:
|
||||
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
|
||||
toggle_node_ui: 'Node-Benutzeroberfläche',
|
||||
|
||||
@@ -22,6 +22,7 @@ window.localisation.en = {
|
||||
active_channels: 'Active Channels',
|
||||
connect_peer: 'Connect Peer',
|
||||
connect: 'Connect',
|
||||
reconnect: 'Reconnect',
|
||||
open_channel: 'Open Channel',
|
||||
open: 'Open',
|
||||
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.',
|
||||
access_wallet_on_mobile: 'Mobile Access',
|
||||
wallet: 'Wallet: ',
|
||||
wallet_name: 'Wallet name',
|
||||
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_desc:
|
||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||
rename_wallet: 'Rename wallet',
|
||||
update_name: 'Update name',
|
||||
fiat_tracking: 'Fiat tracking',
|
||||
fiat_providers: 'Fiat providers',
|
||||
currency: 'Currency',
|
||||
update_currency: 'Update currency',
|
||||
press_to_claim: 'Press to claim bitcoin',
|
||||
@@ -96,6 +101,11 @@ window.localisation.en = {
|
||||
memo: 'Memo',
|
||||
date: 'Date',
|
||||
path: 'Path',
|
||||
internal_memo: 'Internal memo (optional)',
|
||||
internal_memo_hint_receive:
|
||||
"This memo is not shown to the payer but it's stored in the invoice for your reference.",
|
||||
internal_memo_hint_pay:
|
||||
"This memo is not shown to the payee but it's stored in the payment for your reference.",
|
||||
payment_processing: 'Processing payment...',
|
||||
payment_processing: 'Processing payment...',
|
||||
payment_successful: 'Payment successful!',
|
||||
@@ -125,6 +135,7 @@ window.localisation.en = {
|
||||
no_extensions: "You don't have any extensions installed :(",
|
||||
created: 'Created',
|
||||
search_extensions: 'Search extensions',
|
||||
search_wallets: 'Search wallets',
|
||||
extension_sources: 'Extension Sources',
|
||||
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
||||
ext_sources_label:
|
||||
@@ -137,6 +148,7 @@ window.localisation.en = {
|
||||
uninstall: 'Uninstall',
|
||||
drop_db: 'Remove Data',
|
||||
enable: 'Enable',
|
||||
enabled: 'Enabled',
|
||||
pay_to_enable: 'Pay To Enable',
|
||||
enable_extension_details: 'Enable extension for current user',
|
||||
disable: 'Disable',
|
||||
@@ -167,12 +179,33 @@ window.localisation.en = {
|
||||
payment_hash: 'Payment Hash',
|
||||
fee: 'Fee',
|
||||
amount: 'Amount',
|
||||
amount_limits: 'Amount Limits',
|
||||
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',
|
||||
unit: 'Unit',
|
||||
description: 'Description',
|
||||
expiry: 'Expiry',
|
||||
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',
|
||||
update: 'Update',
|
||||
update_available: 'Update {version} available!',
|
||||
@@ -264,6 +297,7 @@ window.localisation.en = {
|
||||
notification_source_label:
|
||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||
more: 'more',
|
||||
more_count: '{count} more',
|
||||
less: 'less',
|
||||
releases: 'Releases',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -278,7 +312,7 @@ window.localisation.en = {
|
||||
callback_url_rules: 'Callback URL Rules',
|
||||
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
||||
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_config: 'Wallet Config',
|
||||
wallet_charts: 'Wallet Charts',
|
||||
@@ -303,6 +337,9 @@ window.localisation.en = {
|
||||
login_with_user_id: 'Login with user ID',
|
||||
or: 'or',
|
||||
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',
|
||||
create_account: 'Create account',
|
||||
account_settings: 'Account Settings',
|
||||
@@ -311,7 +348,7 @@ window.localisation.en = {
|
||||
signin_with_nostr: 'Continue with Nostr',
|
||||
signin_with_google: 'Sign in with Google',
|
||||
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',
|
||||
password: 'Password',
|
||||
password_config: 'Password Config',
|
||||
@@ -320,7 +357,9 @@ window.localisation.en = {
|
||||
change_password: 'Change Password',
|
||||
update_credentials: 'Update Credentials',
|
||||
update_pubkey: 'Update Public Key',
|
||||
nostr_pubkey_tooltip: "Enter this user's Nostr public key (hex value)",
|
||||
set_password: 'Set Password',
|
||||
set_password_tooltip: 'Set a password for this user',
|
||||
invalid_password: 'Password must have at least 8 characters',
|
||||
invalid_password_repeat: 'Passwords do not match',
|
||||
reset_key_generated: 'A reset key has been generated.',
|
||||
@@ -330,6 +369,7 @@ window.localisation.en = {
|
||||
username: 'Username',
|
||||
pubkey: 'Public Key',
|
||||
user_id: 'User ID',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
first_name: 'First Name',
|
||||
last_name: 'Last Name',
|
||||
@@ -339,12 +379,15 @@ window.localisation.en = {
|
||||
update_account: 'Update Account',
|
||||
invalid_username: 'Invalid Username',
|
||||
auth_provider: 'Auth Provider',
|
||||
external_id: 'External ID',
|
||||
my_account: 'My Account',
|
||||
existing_account_question: 'Already have an account?',
|
||||
background_image: 'Background Image',
|
||||
back: 'Back',
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
endpoint: 'Endpoint',
|
||||
api: 'API',
|
||||
api_token: 'API Token',
|
||||
api_tokens: 'API Tokens',
|
||||
access_control_list: 'Access Control List',
|
||||
@@ -356,11 +399,14 @@ window.localisation.en = {
|
||||
gradient_background: 'Gradient Background',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme',
|
||||
visible_wallet_count: 'Visible Wallet Count',
|
||||
admin_settings: 'Admin Settings',
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
pay_with: 'Pay with {provider}',
|
||||
select_payment_provider: 'Select payment provider',
|
||||
wallet_required: 'Wallet *',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: 'Retry Install',
|
||||
@@ -373,6 +419,9 @@ window.localisation.en = {
|
||||
'The {name} extension requires a payment of minimum {amount} sats to enable.',
|
||||
hide_empty_wallets: 'Hide empty wallets',
|
||||
recheck: 'Recheck',
|
||||
check: 'Check',
|
||||
check_connection: 'Check Connection',
|
||||
check_webhook: 'Check Webhook',
|
||||
contributors: 'Contributors',
|
||||
license: 'License',
|
||||
reset_key: 'Reset Key',
|
||||
@@ -434,7 +483,7 @@ window.localisation.en = {
|
||||
node_balance: 'Node Balance: {balance} sats',
|
||||
lnbits_balance: 'LNbits Balance: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||
node_managment: 'Node Management',
|
||||
node_management: 'Node Management',
|
||||
node_management_not_supported:
|
||||
'Node Management not supported by active funding source',
|
||||
toggle_node_ui: 'Node UI',
|
||||
@@ -472,12 +521,16 @@ window.localisation.en = {
|
||||
auth_keycloak_ci_hint:
|
||||
'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_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
currency_settings: 'Currency Settings',
|
||||
allowed_currencies: 'Allowed Currencies',
|
||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||
default_account_currency: 'Default Account Currency',
|
||||
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_desc:
|
||||
'Maximum amount allowed for generating an invoice',
|
||||
@@ -539,6 +592,7 @@ window.localisation.en = {
|
||||
admin_users_label: 'User ID',
|
||||
allowed_users: 'Allowed Users',
|
||||
allowed_users_hint: 'Only these users can use LNbits',
|
||||
allowed_users_hint_feature: 'Only these users can use {feature}',
|
||||
allowed_users_label: 'User ID',
|
||||
allow_creation_user: 'Allow creation of new users',
|
||||
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
||||
@@ -573,5 +627,12 @@ window.localisation.en = {
|
||||
view_column: 'View wallets as rows',
|
||||
filter_payments: 'Filter payments',
|
||||
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',
|
||||
lnbits_balance: 'Saldo de LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
|
||||
node_managment: 'Gestión de nodos',
|
||||
node_management: 'Gestión de nodos',
|
||||
node_management_not_supported:
|
||||
'La gestión de nodos no es compatible con la fuente de financiación activa',
|
||||
toggle_node_ui: 'Interfaz de usuario de nodo',
|
||||
|
||||
@@ -434,7 +434,7 @@ window.localisation.fi = {
|
||||
node_balance: 'Solmun saldo: {balance} sats',
|
||||
lnbits_balance: 'LNbits-saldo: {balance} sat',
|
||||
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
|
||||
node_managment: 'Solmun hallinta',
|
||||
node_management: 'Solmun hallinta',
|
||||
node_management_not_supported:
|
||||
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
|
||||
toggle_node_ui: 'Solmun käyttöliittymä',
|
||||
|
||||
@@ -346,7 +346,7 @@ window.localisation.fr = {
|
||||
node_balance: 'Solde du nœud : {balance} sats',
|
||||
lnbits_balance: 'Solde LNbits : {balance} sats',
|
||||
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
|
||||
node_managment: 'Gestion des nœuds',
|
||||
node_management: 'Gestion des nœuds',
|
||||
node_management_not_supported:
|
||||
"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',
|
||||
|
||||
@@ -343,7 +343,7 @@ window.localisation.it = {
|
||||
node_balance: 'Saldo Nodo: {balance} sats',
|
||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Riserva Percentuale: {percent} %',
|
||||
node_managment: 'Gestione dei nodi',
|
||||
node_management: 'Gestione dei nodi',
|
||||
node_management_not_supported:
|
||||
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
|
||||
toggle_node_ui: 'Interfaccia utente del nodo',
|
||||
|
||||
@@ -334,7 +334,7 @@ window.localisation.jp = {
|
||||
node_balance: 'ノード残高: {balance} サッツ',
|
||||
lnbits_balance: 'LNbits残高: {balance} sats',
|
||||
funding_reserve_percent: '予約パーセント: {percent} %',
|
||||
node_managment: 'ノード管理',
|
||||
node_management: 'ノード管理',
|
||||
node_management_not_supported:
|
||||
'アクティブな資金源ではノード管理がサポートされていません',
|
||||
toggle_node_ui: 'ノードUI',
|
||||
|
||||
@@ -331,7 +331,7 @@ window.localisation.kr = {
|
||||
node_balance: '노드 잔액: {balance} 사토시',
|
||||
lnbits_balance: 'LNbits 잔액: {balance} sats',
|
||||
funding_reserve_percent: '예약 비율: {percent} %',
|
||||
node_managment: '노드 관리',
|
||||
node_management: '노드 관리',
|
||||
node_management_not_supported:
|
||||
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
|
||||
toggle_node_ui: '노드 UI',
|
||||
|
||||
@@ -341,7 +341,7 @@ window.localisation.nl = {
|
||||
node_balance: 'Node Balans: {balance} sats',
|
||||
lnbits_balance: 'LNbits Saldo: {balance} sats',
|
||||
funding_reserve_percent: 'Reservepercentage: {percent} %',
|
||||
node_managment: 'Nodebeheer',
|
||||
node_management: 'Nodebeheer',
|
||||
node_management_not_supported:
|
||||
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
|
||||
toggle_node_ui: 'Node UI',
|
||||
|
||||
@@ -337,7 +337,7 @@ window.localisation.pi = {
|
||||
node_balance: 'Node Balance: {balance} doubloons',
|
||||
lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
|
||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||
node_managment: 'Node Management',
|
||||
node_management: 'Node Management',
|
||||
node_management_not_supported:
|
||||
'Node Management not be supported by active funding source',
|
||||
toggle_node_ui: 'Node Main Deck',
|
||||
|
||||
@@ -337,7 +337,7 @@ window.localisation.pl = {
|
||||
node_balance: 'Saldo węzła: {balance} sats',
|
||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
|
||||
node_managment: 'Zarządzanie węzłami',
|
||||
node_management: 'Zarządzanie węzłami',
|
||||
node_management_not_supported:
|
||||
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
|
||||
toggle_node_ui: 'Interfejs użytkownika węzła',
|
||||
|
||||
@@ -340,7 +340,7 @@ window.localisation.pt = {
|
||||
node_balance: 'Saldo do Nó: {balance} sats',
|
||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percentagem: {percent} %',
|
||||
node_managment: 'Gerenciamento de Nós',
|
||||
node_management: 'Gerenciamento de Nós',
|
||||
node_management_not_supported:
|
||||
'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
|
||||
toggle_node_ui: 'Interface do Usuário de Nó',
|
||||
|
||||
@@ -335,7 +335,7 @@ window.localisation.sk = {
|
||||
node_balance: 'Stav uzla: {balance} sats',
|
||||
lnbits_balance: 'Zostatok LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezervovať percento: {percent} %',
|
||||
node_managment: 'Správa uzlov',
|
||||
node_management: 'Správa uzlov',
|
||||
node_management_not_supported:
|
||||
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
|
||||
toggle_node_ui: 'Používateľské rozhranie uzla',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user