Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce2bfb440 | ||
|
|
a36ab2d408 | ||
|
|
98f732fb6f | ||
|
|
65864cb216 | ||
|
|
114e7b8c3c | ||
|
|
fbfab880f3 | ||
|
|
133afff8ee | ||
|
|
851c952c6d | ||
|
|
8b29148085 | ||
|
|
ad48092549 | ||
|
|
ee449ca612 | ||
|
|
73f45ae859 | ||
|
|
094a9fc1a1 | ||
|
|
5e9919c5c5 | ||
|
|
f91c933919 | ||
|
|
6e9f451419 | ||
|
|
256a8098c6 | ||
|
|
1a02095758 | ||
|
|
ec5b7493b3 | ||
|
|
cdc1a6dc8f | ||
|
|
7d573ef859 | ||
|
|
4bd2a4fba9 | ||
|
|
209c251fb6 | ||
|
|
69a7ef74fd | ||
|
|
35e2c4b0a7 | ||
|
|
2c6dce808e | ||
|
|
fa89313e6f | ||
|
|
76ecf113c3 | ||
|
|
e0749e186e | ||
|
|
0529ee2835 | ||
|
|
09b91f9f79 | ||
|
|
90ab642dcd | ||
|
|
88cf1ac853 | ||
|
|
9aa2194d94 | ||
|
|
ba0f7a01ef | ||
|
|
5765ea0276 | ||
|
|
88b01e6079 | ||
|
|
b0d0901969 | ||
|
|
6563610b6c | ||
|
|
c4aa0b598c | ||
|
|
aab76e691d | ||
|
|
3ed30be93e | ||
|
|
37f99e7f96 | ||
|
|
a16078a6ba | ||
|
|
c4c03d96a3 | ||
|
|
ab3248da15 | ||
|
|
b4b37cd733 | ||
|
|
26eb7a449c | ||
|
|
695e9b6471 | ||
|
|
5c9511ccfe | ||
|
|
c7b7832a88 | ||
|
|
ff24847980 | ||
|
|
659ec31edd | ||
|
|
edb7d66efc | ||
|
|
efcc5e2148 | ||
|
|
55889abd88 | ||
|
|
063aa4fd06 | ||
|
|
7c0a955b30 | ||
|
|
b1a09af82c | ||
|
|
6d2f39d390 | ||
|
|
18496f0dd9 | ||
|
|
acf6c732ad | ||
|
|
b39bd257e0 | ||
|
|
9aeecd4dc0 | ||
|
|
e750165cc3 | ||
|
|
77906bc817 | ||
|
|
63e728710d | ||
|
|
27fd510142 | ||
|
|
e6de66e1b1 | ||
|
|
4071925f65 | ||
|
|
3c4d186dba | ||
|
|
56aebb9d8f | ||
|
|
beee24bd92 | ||
|
|
375b95c004 | ||
|
|
5345ccaf4e | ||
|
|
efc5233399 | ||
|
|
e4d09c6d12 | ||
|
|
7d0545dae1 | ||
|
|
00fccf513e | ||
|
|
34b8490a2d | ||
|
|
cbbba5c4c7 | ||
|
|
4a0ef7fa1a | ||
|
|
cd8804daca | ||
|
|
39e4fa724a | ||
|
|
3b350858c7 | ||
|
|
7bea591879 | ||
|
|
f74fcea35b | ||
|
|
ceb8203353 | ||
|
|
7b29223e95 | ||
|
|
bd19d78834 | ||
|
|
8458b4d84b | ||
|
|
f92ae8bae1 | ||
|
|
3529f9152f | ||
|
|
6a9089fd98 | ||
|
|
5818c3c2ba | ||
|
|
a38de94e49 | ||
|
|
b4801ce0ab | ||
|
|
51bf344d65 | ||
|
|
32cbf16d91 | ||
|
|
c4d0540e76 | ||
|
|
f8b3644029 | ||
|
|
c2fb45d640 | ||
|
|
e339bb6181 | ||
|
|
2dee26b728 | ||
|
|
d774c7a742 | ||
|
|
6c2b312c92 | ||
|
|
56c8783d9a | ||
|
|
ffecd03c4b | ||
|
|
94d5f37723 | ||
|
|
d89bd7409d | ||
|
|
b48489ef32 | ||
|
|
e686ecda51 | ||
|
|
23b4c2cc16 | ||
|
|
1bb29cf0b1 | ||
|
|
5714848434 | ||
|
|
f87e5d4372 | ||
|
|
a0502f1676 | ||
|
|
f6c8a308dc | ||
|
|
6df3933c1b | ||
|
|
ffef085fa7 | ||
|
|
913991c72f | ||
|
|
6f611461f2 | ||
|
|
681385e2a2 | ||
|
|
883d52c303 | ||
|
|
1058461882 | ||
|
|
3a7a88857a | ||
|
|
6834b5e00f | ||
|
|
10093bb465 | ||
|
|
1323a2005b | ||
|
|
bafb4ddf75 | ||
|
|
aa050eaf49 | ||
|
|
0c76efa2b9 | ||
|
|
681730a4af | ||
|
|
63adcb6780 | ||
|
|
35f7821183 | ||
|
|
b185d9585c | ||
|
|
30a8d88ada | ||
|
|
f7e984198b | ||
|
|
eb09e95e88 | ||
|
|
a883e2c7d8 | ||
|
|
e06abb52ce | ||
|
|
9f64b5349d | ||
|
|
a6bbddce41 | ||
|
|
adb9d24673 | ||
|
|
dc85f26964 | ||
|
|
14153b4a8f | ||
|
|
b8a5f3942c | ||
|
|
475ae5736e | ||
|
|
d7f0f4da0c | ||
|
|
f7b3444be4 | ||
|
|
28e32a54a4 | ||
|
|
1833adc0a1 | ||
|
|
b759ec7468 | ||
|
|
2661d12ff1 | ||
|
|
ed419f27ac | ||
|
|
c3efd48108 | ||
|
|
4a28c22d79 | ||
|
|
0d0eb36de4 | ||
|
|
2aa73bfe75 | ||
|
|
ebd080192c | ||
|
|
41abe63f18 | ||
|
|
f61471b0f2 | ||
|
|
9bd037b6e7 | ||
|
|
a4ca88b6a4 | ||
|
|
f2b9aafc51 |
+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.7.0"
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ name: release-rc
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*-rc[0-9]"
|
||||
- "*-rc[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -7,6 +7,7 @@ __pycache__
|
||||
.mypy_cache
|
||||
.vscode
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
*.egg
|
||||
*.egg-info
|
||||
|
||||
+8
-5
@@ -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
|
||||
|
||||
@@ -19,7 +20,8 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
RUN poetry install --only main
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -32,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 \
|
||||
@@ -46,11 +48,12 @@ WORKDIR /app
|
||||
COPY . .
|
||||
COPY --from=builder /app/.venv .venv
|
||||
|
||||
RUN poetry install --only main
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_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. |
|
||||
+61
-11
@@ -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.
|
||||
@@ -29,11 +29,13 @@ It is recommended to use the latest version of Poetry. Make sure you have Python
|
||||
|
||||
### Install Python 3.12
|
||||
|
||||
## Option 2 (recommended): Poetry
|
||||
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
||||
|
||||
### Verify Python version
|
||||
|
||||
```sh
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
sudo apt update -y
|
||||
sudo apt install -y python3.12 python3.12-dev # ensure correct headers needed for secp256k1
|
||||
sudo apt install -y pkg-config python3-dev build-essential # ensure correct headers
|
||||
python3 --version
|
||||
```
|
||||
|
||||
@@ -76,13 +78,38 @@ poetry run lnbits-cli --help
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with `ctrl + x`
|
||||
git pull
|
||||
# Keep your poetry install up to date, this can be done with `poetry self update`
|
||||
# Stop LNbits with `ctrl + x` or with service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update LNbits
|
||||
git pull --rebase
|
||||
|
||||
# Check your poetry version with
|
||||
poetry env list
|
||||
# If version is less 3.12, update it by running
|
||||
poetry env use python3.12
|
||||
poetry env remove python3.9
|
||||
poetry env list
|
||||
|
||||
# Run install and start LNbits with
|
||||
poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
poetry run lnbits
|
||||
|
||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||
```
|
||||
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
|
||||
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||
|
||||
## Option 3: Nix
|
||||
|
||||
```sh
|
||||
@@ -123,7 +150,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
||||
|
||||
## Option 4: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
Use latest version from Docker Hub.
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
@@ -132,7 +159,15 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
build the image yourself
|
||||
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
|
||||
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
|
||||
Example:
|
||||
|
||||
```sh
|
||||
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
|
||||
```
|
||||
|
||||
Build the image yourself.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -143,6 +178,12 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
|
||||
|
||||
```sh
|
||||
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
@@ -550,3 +591,12 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
### FreeBSD notes
|
||||
|
||||
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
|
||||
|
||||
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
|
||||
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
|
||||
3. Rewrite `poetry.lock` file with command `poetry lock`
|
||||
4. Follow install instruction with Poetry
|
||||
|
||||
+34
-1
@@ -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
|
||||
@@ -51,7 +72,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
|
||||
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
|
||||
|
||||
### LNbits
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check install has not already run
|
||||
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.10 and distutils non-interactively
|
||||
sudo apt install -y python3.10 python3.10-distutils
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.10 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
if [ ! -d lnbits/wallets ]; then
|
||||
# Clone the LNbits repository
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone the repository ... FAIL"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure we are in the lnbits directory
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
# Make data folder
|
||||
mkdir data
|
||||
|
||||
# Copy the .env.example to .env
|
||||
cp .env.example .env
|
||||
|
||||
elif [ ! -d lnbits/wallets ]; then
|
||||
# cd into lnbits
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
# Install the dependencies using Poetry
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
# Set environment variables for LNbits
|
||||
export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# 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",
|
||||
]
|
||||
|
||||
+22
-19
@@ -4,6 +4,7 @@ import importlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
@@ -26,7 +27,8 @@ from lnbits.core.crud.extensions import create_installed_extension
|
||||
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.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
@@ -65,17 +67,13 @@ 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):
|
||||
logger.info(f"Starting LNbits Version: {settings.version}")
|
||||
start = time.perf_counter()
|
||||
settings.lnbits_running = True
|
||||
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
@@ -106,7 +104,7 @@ async def startup(app: FastAPI):
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.server_start_stop,
|
||||
{
|
||||
"message": "LNbits server started.",
|
||||
@@ -114,10 +112,13 @@ async def startup(app: FastAPI):
|
||||
},
|
||||
)
|
||||
|
||||
end = time.perf_counter()
|
||||
logger.success(f"LNbits started in {end - start:.2f} seconds.")
|
||||
|
||||
|
||||
async def shutdown():
|
||||
logger.warning("LNbits shutting down...")
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.server_start_stop,
|
||||
{
|
||||
"message": "LNbits server shutting down...",
|
||||
@@ -163,11 +164,13 @@ def create_app() -> FastAPI:
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
static_path = Path("lnbits", "static")
|
||||
static = StaticFiles(directory=static_path)
|
||||
app.mount("/static", static, name="static")
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
|
||||
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/library",
|
||||
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
|
||||
name="library",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
@@ -276,7 +279,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]:
|
||||
"""
|
||||
@@ -450,7 +453,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:
|
||||
@@ -460,12 +463,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)
|
||||
|
||||
+117
-21
@@ -3,8 +3,10 @@ import importlib
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -27,19 +29,21 @@ from lnbits.core.crud import (
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.helpers import is_valid_url, migrate_databases
|
||||
from lnbits.core.models import Payment, PaymentState
|
||||
from lnbits.core.models import Account, Payment, PaymentState
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
)
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.services import check_admin_settings, create_user_account_no_ckeck
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.wallets.base import Wallet
|
||||
from lnbits.wallets.macaroon import load_macaroon
|
||||
|
||||
|
||||
def coro(f):
|
||||
@@ -64,6 +68,13 @@ def db():
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def users():
|
||||
"""
|
||||
Users related commands
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def extensions():
|
||||
"""
|
||||
@@ -71,6 +82,20 @@ def extensions():
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def encrypt():
|
||||
"""
|
||||
Encryption commands
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def decrypt():
|
||||
"""
|
||||
Decryption commands
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> Optional[str]:
|
||||
"""Get the superuser"""
|
||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||
@@ -109,7 +134,7 @@ async def delete_settings():
|
||||
"""Deletes the settings"""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
await conn.execute("DELETE from settings")
|
||||
await conn.execute("DELETE from system_settings")
|
||||
|
||||
|
||||
@db.command("migrate")
|
||||
@@ -180,17 +205,6 @@ async def database_revert_payment(checking_id: str):
|
||||
click.echo(f"Payment '{checking_id}' marked as pending.")
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
"""Delete all accounts that have no wallets"""
|
||||
async with core_db.connect() as conn:
|
||||
delta = days or settings.cleanup_wallets_days
|
||||
delta = delta * 24 * 60 * 60
|
||||
await delete_accounts_no_wallets(delta, conn)
|
||||
|
||||
|
||||
@db.command("check-payments")
|
||||
@click.option("-d", "--days", help="Maximum age of payments in days.")
|
||||
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
|
||||
@@ -271,6 +285,32 @@ async def check_invalid_payments(
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
@users.command("new")
|
||||
@click.option("-u", "--username", required=True, help="Username.")
|
||||
@click.option("-p", "--password", required=True, help="Password.")
|
||||
@coro
|
||||
async def create_user(username: str, password: str):
|
||||
"""Create a new user bypassing the system 'new_accounts_allowed' rules"""
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
username=username,
|
||||
)
|
||||
account.hash_password(password)
|
||||
user = await create_user_account_no_ckeck(account)
|
||||
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
|
||||
|
||||
|
||||
@users.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
"""Delete all accounts that have no wallets"""
|
||||
async with core_db.connect() as conn:
|
||||
delta = days or settings.cleanup_wallets_days
|
||||
delta = delta * 24 * 60 * 60
|
||||
await delete_accounts_no_wallets(delta, conn)
|
||||
|
||||
|
||||
@extensions.command("list")
|
||||
@coro
|
||||
async def extensions_list():
|
||||
@@ -280,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})")
|
||||
|
||||
|
||||
@@ -313,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,
|
||||
@@ -456,6 +495,62 @@ async def extensions_uninstall(
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
@encrypt.command("macaroon")
|
||||
def encrypt_macaroon():
|
||||
"""Encrypts a macaroon (LND wallets)"""
|
||||
_macaroon = getpass("Enter macaroon: ")
|
||||
try:
|
||||
macaroon = load_macaroon(_macaroon)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error loading macaroon: {ex}")
|
||||
return
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting macaroon: {ex}")
|
||||
return
|
||||
click.echo("Encrypted macaroon: ")
|
||||
click.echo(encrypted_macaroon)
|
||||
|
||||
|
||||
@encrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
||||
)
|
||||
def encrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES encrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting payload: {ex}")
|
||||
return
|
||||
click.echo("Encrypted payload: ")
|
||||
click.echo(encrypted)
|
||||
|
||||
|
||||
@decrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
||||
)
|
||||
def decrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES decrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error decrypting payload: {ex}")
|
||||
return
|
||||
click.echo("Decrypted payload: ")
|
||||
click.echo(decrypted)
|
||||
|
||||
|
||||
def main():
|
||||
"""main function"""
|
||||
lnbits_cli()
|
||||
@@ -512,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,10 +5,13 @@ 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
|
||||
from .views.lnurl_api import lnurl_router
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
@@ -34,10 +37,13 @@ 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)
|
||||
app.include_router(lnurl_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -43,6 +43,7 @@ from .settings import (
|
||||
delete_admin_settings,
|
||||
get_admin_settings,
|
||||
get_super_settings,
|
||||
reset_core_settings,
|
||||
update_admin_settings,
|
||||
update_super_user,
|
||||
)
|
||||
@@ -84,86 +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",
|
||||
# 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,7 +1,7 @@
|
||||
from time import time
|
||||
from typing import Optional, Tuple
|
||||
from typing import Any, Optional
|
||||
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentState
|
||||
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||
@@ -50,11 +50,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,21 +82,28 @@ 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,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
failed: bool = False,
|
||||
@@ -110,8 +119,7 @@ async def get_payments_paginated(
|
||||
- complete | pending | failed | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict = {
|
||||
"wallet_id": wallet_id,
|
||||
values: dict[str, Any] = {
|
||||
"time": since,
|
||||
}
|
||||
clause: list[str] = []
|
||||
@@ -120,7 +128,11 @@ async def get_payments_paginated(
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
clause.append(only_user_wallets)
|
||||
|
||||
if complete and pending:
|
||||
clause.append(
|
||||
@@ -223,21 +235,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())},
|
||||
)
|
||||
|
||||
|
||||
@@ -250,7 +264,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(
|
||||
@@ -264,7 +279,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,
|
||||
)
|
||||
@@ -317,16 +332,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:
|
||||
@@ -358,20 +377,30 @@ 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,
|
||||
)
|
||||
@@ -381,18 +410,26 @@ 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,
|
||||
@@ -423,6 +460,7 @@ 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]:
|
||||
|
||||
@@ -441,9 +479,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,
|
||||
@@ -455,7 +499,7 @@ async def get_wallets_stats(
|
||||
{clauses}
|
||||
GROUP BY apipayments.wallet_id
|
||||
ORDER BY payments_count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentWalletStats,
|
||||
)
|
||||
@@ -480,11 +524,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,
|
||||
)
|
||||
|
||||
@@ -508,7 +552,7 @@ async def is_internal_status_success(
|
||||
return payment.status == PaymentState.SUCCESS.value
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = :status
|
||||
@@ -516,3 +560,14 @@ async def mark_webhook_sent(payment_hash: str, status: int) -> 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,12 +56,29 @@ 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
|
||||
|
||||
|
||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
|
||||
await db.execute(
|
||||
"DELETE FROM system_settings WHERE tag = :tag",
|
||||
{"tag": tag},
|
||||
)
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
@@ -70,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,
|
||||
)
|
||||
|
||||
|
||||
+60
-17
@@ -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,19 +127,57 @@ 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]:
|
||||
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(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
||||
row = result.mappings().first()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .audit import AuditEntry, AuditFilters
|
||||
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
|
||||
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
|
||||
from .misc import (
|
||||
BalanceDelta,
|
||||
Callback,
|
||||
@@ -9,6 +9,7 @@ from .misc import (
|
||||
SimpleStatus,
|
||||
)
|
||||
from .payments import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
CreatePayment,
|
||||
DecodePayment,
|
||||
@@ -23,6 +24,7 @@ from .payments import (
|
||||
PaymentsStatusCount,
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -48,62 +50,55 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
# lnurl
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"PayLnurlWData",
|
||||
# misc
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"CancelInvoice",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"DbVersion",
|
||||
"SimpleStatus",
|
||||
# payments
|
||||
"CreateInvoice",
|
||||
"CreateLnurlPayment",
|
||||
"CreateLnurlWithdraw",
|
||||
"CreatePayment",
|
||||
"CreateUser",
|
||||
"CreateWallet",
|
||||
"CreateWebPushSubscription",
|
||||
"DbVersion",
|
||||
"DecodePayment",
|
||||
"KeyType",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"PayInvoice",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentsStatusCount",
|
||||
"PaymentWalletStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
# tinyurl
|
||||
"TinyURL",
|
||||
# users
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"UserAcls",
|
||||
"CreateUser",
|
||||
"PaymentWalletStats",
|
||||
"PaymentsStatusCount",
|
||||
"RegisterUser",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"ResetUserPassword",
|
||||
"SettleInvoice",
|
||||
"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()
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnurl import Lnurl, LnurlPayResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
class CreateLnurlPayment(BaseModel):
|
||||
res: LnurlPayResponse
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
internal_memo: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl_w: str
|
||||
class CreateLnurlWithdraw(BaseModel):
|
||||
lnurl_w: Lnurl
|
||||
|
||||
@@ -46,3 +46,8 @@ class SimpleItem(BaseModel):
|
||||
class DbVersion(BaseModel):
|
||||
db: str
|
||||
version: int
|
||||
|
||||
|
||||
class Image(BaseModel):
|
||||
filename: str
|
||||
directory: str = "library"
|
||||
|
||||
@@ -2,6 +2,8 @@ from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.models.users import UserNotifications
|
||||
|
||||
|
||||
class NotificationType(Enum):
|
||||
server_status = "server_status"
|
||||
@@ -18,6 +20,7 @@ class NotificationType(Enum):
|
||||
class NotificationMessage(BaseModel):
|
||||
message_type: NotificationType
|
||||
values: dict
|
||||
user_notifications: UserNotifications | None = None
|
||||
|
||||
|
||||
NOTIFICATION_TEMPLATES = {
|
||||
@@ -29,7 +32,8 @@ NOTIFICATION_TEMPLATES = {
|
||||
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
|
||||
*Pending payments*: `{pending_payments_count}`.
|
||||
*Failed payments*: `{failed_payments_count}`.
|
||||
*LNbits balance*: `{lnbits_balance_sats}` sats.""",
|
||||
*LNbits balance*: `{lnbits_balance_sats}` sats.
|
||||
*Node balance*: `{node_balance_sats}` sats.""",
|
||||
"server_start_stop": """*SERVER*
|
||||
{message}
|
||||
*Time*: `{up_time}` seconds.
|
||||
|
||||
@@ -5,9 +5,17 @@ from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlWithdrawResponse
|
||||
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,11 +68,13 @@ 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
|
||||
webhook: str | None = None
|
||||
webhook_status: int | None = None
|
||||
webhook_status: str | None = None
|
||||
preimage: str | None = None
|
||||
tag: str | None = None
|
||||
extension: str | None = None
|
||||
@@ -107,14 +117,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 +142,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"]
|
||||
@@ -198,14 +250,27 @@ class CreateInvoice(BaseModel):
|
||||
internal: bool = False
|
||||
out: bool = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: str | None = None
|
||||
memo: str | None = Query(None, max_length=640)
|
||||
description_hash: str | None = None
|
||||
unhashed_description: str | None = None
|
||||
payment_hash: str | None = Query(
|
||||
None,
|
||||
description="The payment hash of the hold invoice.",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_callback: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
if v:
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
@@ -220,3 +285,31 @@ class PaymentsStatusCount(BaseModel):
|
||||
outgoing: int = 0
|
||||
failed: int = 0
|
||||
pending: int = 0
|
||||
|
||||
|
||||
class SettleInvoice(BaseModel):
|
||||
preimage: str = Field(
|
||||
...,
|
||||
description="The preimage of the payment hash to settle the invoice.",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
@validator("preimage")
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
|
||||
class CancelInvoice(BaseModel):
|
||||
payment_hash: str = Field(
|
||||
...,
|
||||
description="The payment hash of the invoice to cancel.",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Keycloak SSO Login Helper
|
||||
"""
|
||||
"""Keycloak SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -3,18 +3,32 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from bcrypt import checkpw, gensalt, hashpw
|
||||
from fastapi import Query
|
||||
from passlib.context import CryptContext
|
||||
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
|
||||
|
||||
|
||||
class UserNotifications(BaseModel):
|
||||
nostr_identifier: str | None = None
|
||||
telegram_chat_id: str | None = None
|
||||
email_address: str | None = None
|
||||
excluded_wallets: list[str] = []
|
||||
outgoing_payments_sats: int = 0
|
||||
incoming_payments_sats: int = 0
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: bool | None = False
|
||||
first_name: str | None = None
|
||||
@@ -27,6 +41,11 @@ 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
|
||||
|
||||
notifications: UserNotifications = UserNotifications()
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
@@ -90,6 +109,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,24 +121,28 @@ 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"""
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
self.password_hash = pwd_context.hash(password)
|
||||
salt = gensalt()
|
||||
hashed_pw = hashpw(password.encode(), salt)
|
||||
if not hashed_pw:
|
||||
raise ValueError("Password hashing failed.")
|
||||
self.password_hash = hashed_pw.decode()
|
||||
return self.password_hash
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""returns True if the password matches the hash"""
|
||||
if not self.password_hash:
|
||||
return False
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
return pwd_context.verify(password, self.password_hash)
|
||||
return checkpw(password.encode(), self.password_hash.encode())
|
||||
|
||||
def validate_fields(self):
|
||||
if self.username and not is_valid_username(self.username):
|
||||
@@ -127,6 +151,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 +169,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 +190,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 +201,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 +243,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
|
||||
|
||||
@@ -2,17 +2,24 @@ from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
|
||||
from .notifications import enqueue_notification, send_payment_notification
|
||||
from .lnurl import fetch_lnurl_pay_request
|
||||
from .notifications import enqueue_admin_notification, send_payment_notification
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
cancel_hold_invoice,
|
||||
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,
|
||||
settle_hold_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
)
|
||||
@@ -24,44 +31,41 @@ from .users import (
|
||||
check_admin_settings,
|
||||
create_user_account,
|
||||
create_user_account_no_ckeck,
|
||||
init_admin_settings,
|
||||
update_user_account,
|
||||
update_user_extensions,
|
||||
)
|
||||
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",
|
||||
"cancel_hold_invoice",
|
||||
"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",
|
||||
"init_admin_settings",
|
||||
"create_wallet_invoice",
|
||||
"enqueue_admin_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"fetch_lnurl_pay_request",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"pay_invoice",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"settle_hold_invoice",
|
||||
"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,194 @@
|
||||
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)
|
||||
if not fiat_provider:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Fiat provider '{provider}' not found.",
|
||||
)
|
||||
status = await fiat_provider.status()
|
||||
if status.error_message:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Cconnection test failed: {status.error_message}",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message="Connection test successful." f" Balance: {status.balance}.",
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
|
||||
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
|
||||
f"Balance delta reached: {status.delta_sats} sats."
|
||||
f" Switch to void wallet: {use_voidwallet}."
|
||||
)
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.watchdog_check,
|
||||
{
|
||||
"delta_sats": status.delta_sats,
|
||||
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
return
|
||||
if status.delta_sats != settings.latest_balance_delta_sats:
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.balance_delta,
|
||||
{
|
||||
"delta_sats": status.delta_sats,
|
||||
|
||||
+22
-151
@@ -1,158 +1,29 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from lnurl import LnurlPayActionResponse
|
||||
from lnurl import execute_pay_request as lnurlp
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url, url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.core.models import CreateLnurlPayment
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .payments import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
|
||||
"""
|
||||
Pay an LNURL payment request.
|
||||
|
||||
res = {}
|
||||
raises `LnurlResponseException` if pay request fails
|
||||
"""
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
check_callback_url(str(lnurl))
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
if data.unit and data.unit != "sat":
|
||||
# shift to float with 2 decimal places
|
||||
amount = round(data.amount / 1000, 2)
|
||||
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
|
||||
amount_msat *= 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
params = {"k1": res["k1"], "pr": payment_request}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||
|
||||
if len(b) == 0:
|
||||
# ensure there's at least one byte when the int is zero
|
||||
return bytes([0])
|
||||
|
||||
if b[0] & 0x80 != 0:
|
||||
# ensure it doesn't start with a 0x80 and so it isn't
|
||||
# interpreted as a negative number
|
||||
return bytes([0]) + b
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r: int, s: int, order: int):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here see:
|
||||
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||
if s > order // 2:
|
||||
s = order - s
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r_temp = int_to_bytes_suitable_der(r)
|
||||
s_temp = int_to_bytes_suitable_der(s)
|
||||
|
||||
r_len = len(r_temp)
|
||||
s_len = len(s_temp)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_temp)
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_temp)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
check_callback_url(callback)
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
"k1": k1.hex(),
|
||||
"key": key.verifying_key.to_string("compressed").hex(),
|
||||
"sig": sig.hex(),
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] == "OK":
|
||||
return None
|
||||
|
||||
return LnurlErrorResponse(reason=resp["reason"])
|
||||
except (KeyError, json.decoder.JSONDecodeError):
|
||||
return LnurlErrorResponse(
|
||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||
)
|
||||
return await lnurlp(
|
||||
data.res,
|
||||
msat=str(amount_msat),
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -16,23 +16,25 @@ from lnbits.core.crud import (
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
NotificationMessage,
|
||||
NotificationType,
|
||||
)
|
||||
from lnbits.core.models.users import UserNotifications
|
||||
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
|
||||
from lnbits.core.services.websockets import websocket_manager
|
||||
from lnbits.helpers import check_callback_url
|
||||
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:
|
||||
if not is_message_type_enabled(message_type):
|
||||
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
|
||||
if not _is_message_type_enabled(message_type):
|
||||
return
|
||||
try:
|
||||
notifications_queue.put_nowait(
|
||||
@@ -42,62 +44,125 @@ 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()
|
||||
def enqueue_user_notification(
|
||||
message_type: NotificationType, values: dict, user_notifications: UserNotifications
|
||||
) -> None:
|
||||
try:
|
||||
notifications_queue.put_nowait(
|
||||
NotificationMessage(
|
||||
message_type=message_type,
|
||||
values=values,
|
||||
user_notifications=user_notifications,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enqueuing notification: {e}")
|
||||
|
||||
|
||||
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)
|
||||
user_notifications = notification_message.user_notifications
|
||||
if user_notifications:
|
||||
await send_user_notification(user_notifications, text, message_type)
|
||||
else:
|
||||
await send_admin_notification(text, message_type)
|
||||
|
||||
|
||||
async def send_admin_notification(
|
||||
message: str,
|
||||
message_type: Optional[str] = None,
|
||||
) -> None:
|
||||
return await send_notification(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
|
||||
|
||||
async def send_user_notification(
|
||||
user_notifications: UserNotifications,
|
||||
message: str,
|
||||
message_type: Optional[str] = None,
|
||||
) -> None:
|
||||
|
||||
email_address = (
|
||||
[user_notifications.email_address] if user_notifications.email_address else []
|
||||
)
|
||||
nostr_identifiers = (
|
||||
[user_notifications.nostr_identifier]
|
||||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
|
||||
|
||||
async def send_notification(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: Optional[str] = None,
|
||||
) -> None:
|
||||
try:
|
||||
if settings.lnbits_telegram_notifications_enabled:
|
||||
await send_telegram_notification(message)
|
||||
if telegram_chat_id and settings.is_telegram_notifications_configured():
|
||||
await send_telegram_notification(telegram_chat_id, message)
|
||||
logger.debug(f"Sent telegram notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending telegram notification {message_type}: {e}")
|
||||
|
||||
try:
|
||||
if settings.lnbits_nostr_notifications_enabled:
|
||||
await send_nostr_notification(message)
|
||||
if nostr_identifiers and settings.is_nostr_notifications_configured():
|
||||
await send_nostr_notifications(nostr_identifiers, message)
|
||||
logger.debug(f"Sent nostr notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending nostr notification {message_type}: {e}")
|
||||
try:
|
||||
if settings.lnbits_email_notifications_enabled:
|
||||
await send_email_notification(message)
|
||||
if email_addresses and settings.lnbits_email_notifications_enabled:
|
||||
await send_email_notification(email_addresses, message)
|
||||
logger.debug(f"Sent email notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email notification {message_type}: {e}")
|
||||
|
||||
|
||||
async def send_nostr_notification(message: str) -> dict:
|
||||
for i in settings.lnbits_nostr_notifications_identifiers:
|
||||
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
|
||||
success_sent: list[str] = []
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
identifier = await fetch_nip5_details(i)
|
||||
user_pubkey = identifier[0]
|
||||
relays = identifier[1]
|
||||
server_private_key = normalize_private_key(
|
||||
settings.lnbits_nostr_notifications_private_key
|
||||
)
|
||||
await send_nostr_dm(
|
||||
server_private_key,
|
||||
user_pubkey,
|
||||
message,
|
||||
relays,
|
||||
)
|
||||
await send_nostr_notification(identifier, message)
|
||||
success_sent.append(identifier)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error notifying identifier {i}: {e}")
|
||||
|
||||
return {"status": "ok"}
|
||||
logger.warning(f"Error notifying identifier {identifier}: {e}")
|
||||
return success_sent
|
||||
|
||||
|
||||
async def send_telegram_notification(message: str) -> dict:
|
||||
async def send_nostr_notification(identifier: str, message: str):
|
||||
nip5 = await fetch_nip5_details(identifier)
|
||||
user_pubkey = nip5[0]
|
||||
relays = nip5[1]
|
||||
server_private_key = normalize_private_key(
|
||||
settings.lnbits_nostr_notifications_private_key
|
||||
)
|
||||
await send_nostr_dm(
|
||||
server_private_key,
|
||||
user_pubkey,
|
||||
message,
|
||||
relays,
|
||||
)
|
||||
|
||||
|
||||
async def send_telegram_notification(chat_id: str, message: str) -> dict:
|
||||
return await send_telegram_message(
|
||||
settings.lnbits_telegram_notifications_access_token,
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
chat_id,
|
||||
message,
|
||||
)
|
||||
|
||||
@@ -111,76 +176,56 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
|
||||
return response.json()
|
||||
|
||||
|
||||
async def send_email_notification(message: str) -> dict:
|
||||
await send_email(
|
||||
settings.lnbits_email_notifications_server,
|
||||
settings.lnbits_email_notifications_port,
|
||||
settings.lnbits_email_notifications_password,
|
||||
settings.lnbits_email_notifications_email,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
"LNbits Notification",
|
||||
message,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
async def send_email_notification(
|
||||
to_emails: list[str], message: str, subject: str = "LNbits Notification"
|
||||
) -> dict:
|
||||
if not settings.lnbits_email_notifications_enabled:
|
||||
return {"status": "error", "message": "Email notifications are disabled"}
|
||||
try:
|
||||
await send_email(
|
||||
settings.lnbits_email_notifications_server,
|
||||
settings.lnbits_email_notifications_port,
|
||||
settings.lnbits_email_notifications_username,
|
||||
settings.lnbits_email_notifications_password,
|
||||
settings.lnbits_email_notifications_email,
|
||||
to_emails,
|
||||
subject,
|
||||
message,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email notification: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
async def send_email(
|
||||
server: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
from_email: str,
|
||||
to_emails: list,
|
||||
to_emails: list[str],
|
||||
subject: str,
|
||||
message: str,
|
||||
):
|
||||
) -> bool:
|
||||
if not is_valid_email_address(from_email):
|
||||
raise ValueError(f"Invalid from email address: {from_email}")
|
||||
if len(to_emails) == 0:
|
||||
raise ValueError("No email addresses provided")
|
||||
for email in to_emails:
|
||||
if not is_valid_email_address(email):
|
||||
raise ValueError(f"Invalid email address: {email}")
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = from_email
|
||||
msg["To"] = ", ".join(to_emails)
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
try:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(from_email, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to send email: {e}")
|
||||
|
||||
|
||||
def is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.balance_update:
|
||||
return settings.lnbits_notification_credit_debit
|
||||
if message_type == NotificationType.settings_update:
|
||||
return settings.lnbits_notification_settings_update
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_changed
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
return settings.lnbits_notification_server_status_hours > 0
|
||||
if message_type == NotificationType.incoming_payment:
|
||||
return settings.lnbits_notification_incoming_payment_amount_sats > 0
|
||||
if message_type == NotificationType.outgoing_payment:
|
||||
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> Tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
text = meesage_value.format(**notification_message.values)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting notification message: {e}")
|
||||
text = meesage_value
|
||||
text = f"""[{settings.lnbits_site_title}]\n{text}"""
|
||||
return message_type, text
|
||||
username = username if len(username) > 0 else from_email
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
@@ -190,24 +235,23 @@ async def dispatch_webhook(payment: Payment):
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||
return await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
check_callback_url(payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
|
||||
logger.warning(
|
||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||
|
||||
|
||||
@@ -215,18 +259,21 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending websocket payment notification", e)
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
send_chat_payment_notification(wallet, payment)
|
||||
await send_chat_payment_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending chat payment notification", e)
|
||||
logger.error(f"Error sending chat payment notification {e!s}")
|
||||
try:
|
||||
await send_payment_push_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending push payment notification", e)
|
||||
logger.error(f"Error sending push payment notification {e!s}")
|
||||
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
try:
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error dispatching webhook: {e!s}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
@@ -246,11 +293,12 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocket_manager.send_data(payment_notification, wallet.adminkey)
|
||||
|
||||
await websocket_manager.send_data(
|
||||
json.dumps({"pending": payment.pending}), payment.payment_hash
|
||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
||||
payment.payment_hash,
|
||||
)
|
||||
|
||||
|
||||
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
amount_sats = abs(payment.sat)
|
||||
values: dict = {
|
||||
"wallet_id": wallet.id,
|
||||
@@ -265,10 +313,23 @@ def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
|
||||
if payment.is_out:
|
||||
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
|
||||
enqueue_notification(NotificationType.outgoing_payment, values)
|
||||
else:
|
||||
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
||||
enqueue_notification(NotificationType.incoming_payment, values)
|
||||
enqueue_admin_notification(NotificationType.outgoing_payment, values)
|
||||
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
||||
enqueue_admin_notification(NotificationType.incoming_payment, values)
|
||||
|
||||
user = await get_user(wallet.user)
|
||||
user_notifications = user.extra.notifications if user else None
|
||||
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
|
||||
out_limit = user_notifications.outgoing_payments_sats
|
||||
in_limit = user_notifications.incoming_payments_sats
|
||||
if payment.is_out and (amount_sats >= out_limit):
|
||||
enqueue_user_notification(
|
||||
NotificationType.outgoing_payment, values, user_notifications
|
||||
)
|
||||
elif amount_sats >= in_limit:
|
||||
enqueue_user_notification(
|
||||
NotificationType.incoming_payment, values, user_notifications
|
||||
)
|
||||
|
||||
|
||||
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
|
||||
@@ -312,3 +373,38 @@ async def send_push_notification(subscription, title, body, url=""):
|
||||
f"failed sending push notification: "
|
||||
f"{e.response.text if e.response else e}"
|
||||
)
|
||||
|
||||
|
||||
def _is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.balance_update:
|
||||
return settings.lnbits_notification_credit_debit
|
||||
if message_type == NotificationType.settings_update:
|
||||
return settings.lnbits_notification_settings_update
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_changed
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
return settings.lnbits_notification_server_status_hours > 0
|
||||
if message_type == NotificationType.incoming_payment:
|
||||
return settings.lnbits_notification_incoming_payment_amount_sats > 0
|
||||
if message_type == NotificationType.outgoing_payment:
|
||||
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
text = meesage_value.format(**notification_message.values)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting notification message: {e}")
|
||||
text = meesage_value
|
||||
text = f"""[{settings.lnbits_site_title}]\n{text}"""
|
||||
return message_type, text
|
||||
|
||||
@@ -6,20 +6,26 @@ from typing import Optional
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from bolt11 import encode as bolt11_encode
|
||||
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
||||
from lnurl import execute_withdraw as lnurl_withdraw
|
||||
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.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
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.utils.crypto import fake_privkey, random_secret_and_hash
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -44,6 +50,9 @@ from ..models import (
|
||||
)
|
||||
from .notifications import send_payment_notification
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
async def pay_invoice(
|
||||
*,
|
||||
@@ -58,7 +67,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
|
||||
@@ -79,12 +89,142 @@ async def pay_invoice(
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(payment, new_conn)
|
||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
||||
|
||||
return payment
|
||||
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)
|
||||
if not fiat_provider:
|
||||
raise InvoiceError("No fiat provider found.", status="failed")
|
||||
|
||||
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 = None
|
||||
unhashed_description = None
|
||||
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,
|
||||
payment_hash=data.payment_hash,
|
||||
)
|
||||
|
||||
if data.lnurl_withdraw:
|
||||
try:
|
||||
check_callback_url(data.lnurl_withdraw.callback)
|
||||
res = await lnurl_withdraw(
|
||||
data.lnurl_withdraw,
|
||||
payment.bolt11,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=10,
|
||||
)
|
||||
if isinstance(res, LnurlErrorResponse):
|
||||
payment.extra["lnurl_response"] = res.reason
|
||||
payment.status = "failed"
|
||||
elif isinstance(res, LnurlSuccessResponse):
|
||||
payment.extra["lnurl_response"] = True
|
||||
payment.status = "success"
|
||||
except Exception as exc:
|
||||
payment.extra["lnurl_response"] = str(exc)
|
||||
payment.status = "failed"
|
||||
# updating to payment here would run into a race condition
|
||||
# with the payment listeners and they will overwrite each other
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
@@ -99,6 +239,7 @@ async def create_invoice(
|
||||
extra: Optional[dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
payment_hash: str | None = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -108,7 +249,7 @@ async def create_invoice(
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
invoice_memo = None if description_hash else memo[:640]
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
funding_source = fake_wallet if internal else get_funding_source()
|
||||
@@ -132,38 +273,54 @@ async def create_invoice(
|
||||
status="failed",
|
||||
)
|
||||
|
||||
(
|
||||
ok,
|
||||
checking_id,
|
||||
payment_request,
|
||||
error_message,
|
||||
) = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceError(
|
||||
error_message or "unexpected backend error.", status="pending"
|
||||
if payment_hash:
|
||||
try:
|
||||
invoice_response = await funding_source.create_hold_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
payment_hash=payment_hash,
|
||||
description_hash=description_hash,
|
||||
)
|
||||
extra["hold_invoice"] = True
|
||||
except UnsupportedError as exc:
|
||||
raise InvoiceError(
|
||||
"Hold invoices are not supported by the funding source.",
|
||||
status="failed",
|
||||
) from exc
|
||||
else:
|
||||
invoice_response = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
|
||||
invoice = bolt11_decode(payment_request)
|
||||
if (
|
||||
not invoice_response.ok
|
||||
or not invoice_response.payment_request
|
||||
or not invoice_response.checking_id
|
||||
):
|
||||
raise InvoiceError(
|
||||
message=invoice_response.error_message or "unexpected backend error.",
|
||||
status="pending",
|
||||
)
|
||||
invoice = bolt11_decode(invoice_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
bolt11=payment_request,
|
||||
bolt11=invoice_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=invoice_response.preimage,
|
||||
amount_msat=amount_sat * 1000,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=checking_id,
|
||||
checking_id=invoice_response.checking_id,
|
||||
data=create_payment_model,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -181,17 +338,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:
|
||||
@@ -199,6 +384,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
|
||||
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
amount_msat = abs(amount_msat)
|
||||
return settings.fee_reserve(amount_msat, internal)
|
||||
|
||||
|
||||
@@ -218,6 +404,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,
|
||||
@@ -276,10 +482,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(
|
||||
@@ -395,8 +598,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))
|
||||
@@ -409,13 +613,16 @@ async def get_payments_daily_stats(
|
||||
|
||||
data: list[PaymentDailyStats] = []
|
||||
|
||||
start_date = min(data_in[0].date, data_out[0].date)
|
||||
end_date = max(data_in[-1].date, data_out[-1].date)
|
||||
def _tz(dt: datetime) -> datetime:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
|
||||
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
|
||||
delta = timedelta(days=1)
|
||||
while start_date <= end_date:
|
||||
|
||||
data_in_point = next((x for x in data_in if x.date == start_date), _none)
|
||||
data_out_point = next((x for x in data_out if x.date == start_date), _none)
|
||||
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
|
||||
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none)
|
||||
|
||||
balance_total += data_in_point.balance + data_out_point.balance
|
||||
data.append(
|
||||
@@ -437,11 +644,27 @@ async def get_payments_daily_stats(
|
||||
return data
|
||||
|
||||
|
||||
async def _pay_invoice(wallet, create_payment_model, conn):
|
||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||
if not payment:
|
||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||
return payment
|
||||
async def _pay_invoice(
|
||||
wallet_id: str,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
async with payment_lock:
|
||||
if wallet_id not in wallets_payments_lock:
|
||||
wallets_payments_lock[wallet_id] = asyncio.Lock()
|
||||
|
||||
async with wallets_payments_lock[wallet_id]:
|
||||
# get the wallet again to make sure we have the latest balance
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
raise PaymentError(
|
||||
f"Could not fetch wallet '{wallet_id}'.", status="failed"
|
||||
)
|
||||
|
||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||
if not payment:
|
||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||
return payment
|
||||
|
||||
|
||||
async def _pay_internal_invoice(
|
||||
@@ -476,12 +699,15 @@ async def _pay_internal_invoice(
|
||||
):
|
||||
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
|
||||
create_payment_model.fee = abs(fee_reserve_total_msat)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
# release the preimage
|
||||
create_payment_model.preimage = internal_invoice.preimage
|
||||
|
||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
payment = await create_payment(
|
||||
@@ -519,6 +745,8 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat):
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
@@ -538,57 +766,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
|
||||
|
||||
|
||||
@@ -622,8 +848,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",
|
||||
@@ -676,18 +901,22 @@ def _validate_payment_request(
|
||||
|
||||
|
||||
async def _credit_service_fee_wallet(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
wallet: Wallet, payment: Payment, 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:
|
||||
return
|
||||
|
||||
memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_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}",
|
||||
@@ -695,3 +924,84 @@ 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.",
|
||||
)
|
||||
|
||||
|
||||
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
|
||||
if verify_preimage(preimage, payment.payment_hash) is False:
|
||||
raise InvoiceError("Invalid preimage.", status="failed")
|
||||
|
||||
funding_source = get_funding_source()
|
||||
response = await funding_source.settle_hold_invoice(preimage=preimage)
|
||||
|
||||
if not response.ok:
|
||||
raise InvoiceError(
|
||||
response.error_message or "Unexpected backend error.", status="failed"
|
||||
)
|
||||
|
||||
payment.preimage = preimage
|
||||
payment.extra["hold_invoice_settled"] = True
|
||||
await update_payment(payment)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
|
||||
funding_source = get_funding_source()
|
||||
response = await funding_source.cancel_hold_invoice(
|
||||
payment_hash=payment.payment_hash
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise InvoiceError(
|
||||
response.error_message or "Unexpected backend error.", status="failed"
|
||||
)
|
||||
|
||||
payment.status = PaymentState.FAILED
|
||||
payment.extra["hold_invoice_cancelled"] = True
|
||||
await update_payment(payment)
|
||||
|
||||
return response
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,6 @@ from lnbits.core.models.extensions import UserExtension
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
|
||||
@@ -47,7 +46,9 @@ async def create_user_account(
|
||||
|
||||
|
||||
async def create_user_account_no_ckeck(
|
||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||
account: Optional[Account] = None,
|
||||
wallet_name: Optional[str] = None,
|
||||
default_exts: Optional[list[str]] = None,
|
||||
) -> User:
|
||||
|
||||
if account:
|
||||
@@ -70,12 +71,17 @@ async def create_user_account_no_ckeck(
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
)
|
||||
|
||||
for ext_id in settings.lnbits_user_default_extensions:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await update_user_extension(user_ext)
|
||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
||||
for ext_id in user_extensions:
|
||||
try:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await create_user_extension(user_ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
assert user, "Cannot find user for account."
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
@@ -154,14 +160,6 @@ async def check_admin_settings():
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
and settings.lnbits_saas_secret
|
||||
and settings.lnbits_saas_instance_id
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
account = await get_account(settings.super_user)
|
||||
if account and account.extra and account.extra.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
+15
-14
@@ -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 (
|
||||
@@ -21,7 +22,7 @@ from lnbits.core.services.funding_source import (
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_notification,
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
send_payment_notification,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -86,10 +87,10 @@ async def _notify_server_status():
|
||||
"lnbits_balance_sats": status.lnbits_balance_sats,
|
||||
"node_balance_sats": status.node_balance_sats,
|
||||
}
|
||||
enqueue_notification(NotificationType.server_status, values)
|
||||
enqueue_admin_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,17 +117,17 @@ 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:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.log("error", ex)
|
||||
logger.warning("Payment notification error", ex)
|
||||
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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<q-tab-panel name="library">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('image_library')"></span>
|
||||
</h6>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Add Image"
|
||||
@click="$refs.imageInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="imageInput"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
style="display: none"
|
||||
@change="onImageInput"
|
||||
/>
|
||||
</q-card-section>
|
||||
<div class="row q-col-gutter-sm q-pa-sm">
|
||||
<div
|
||||
v-for="image in library_images"
|
||||
:key="image.filename"
|
||||
class="col-6 col-sm-4 col-md-3 col-lg-2"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<q-card class="q-mb-sm">
|
||||
<q-img :src="image.url" style="height: 150px" />
|
||||
|
||||
<q-card-section
|
||||
class="q-pt-md q-pb-md row items-center justify-between"
|
||||
>
|
||||
<small
|
||||
><div
|
||||
class="text-caption ellipsis"
|
||||
style="max-width: 100px"
|
||||
:title="image.filename"
|
||||
v-text="image.filename"
|
||||
></div
|
||||
></small>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
@click="copyText(image.url)"
|
||||
:title="$t('copy')"
|
||||
><q-tooltip>Copy image link</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="deleteImage(image.filename)"
|
||||
:title="$t('delete')"
|
||||
><q-tooltip>Delete image</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="library_images.length === 0" class="q-pa-xl">
|
||||
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -150,7 +150,6 @@
|
||||
<div class="col-sm-12">
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<strong v-text="$t('notifications_email_config')"></strong>
|
||||
<q-item tag="label" v-ripple>
|
||||
@@ -194,6 +193,24 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('notifications_send_email_username')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('notifications_send_email_username_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
filled
|
||||
v-model="formData.lnbits_email_notifications_username"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
@@ -212,7 +229,17 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-btn
|
||||
@click="sendTestEmail()"
|
||||
:label="$t('notifications_send_test_email')"
|
||||
color="primary"
|
||||
class="q-mt-md"
|
||||
></q-btn>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
@@ -249,9 +276,6 @@
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_title"
|
||||
:label="$t('ui_site_title')"
|
||||
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
@@ -53,17 +53,6 @@
|
||||
:label="$t('lnbits_wallet')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p><span v-text="$t('denomination')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_denomination"
|
||||
label="sats"
|
||||
:hint="$t('denomination_hint')"
|
||||
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
|
||||
<q-input
|
||||
@@ -156,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"
|
||||
@@ -165,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"
|
||||
@@ -176,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"
|
||||
@@ -185,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"
|
||||
@@ -194,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"
|
||||
@@ -204,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"
|
||||
@@ -149,6 +156,14 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('audit')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="library"
|
||||
icon="image"
|
||||
:label="$q.screen.gt.sm ? $t('library') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('library')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
style="word-break: break-all"
|
||||
name="site_customisation"
|
||||
@@ -163,22 +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"%}
|
||||
</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>
|
||||
@@ -85,7 +107,12 @@
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
@@ -96,7 +123,8 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||
<i v-text="wallet.inkey"></i>"</code
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
|
||||
>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -112,7 +140,12 @@
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>,
|
||||
@@ -130,8 +163,9 @@
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||
application/json"</code
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -218,7 +252,12 @@
|
||||
/api/v1/payments/<payment_hash></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
@@ -227,11 +266,33 @@
|
||||
<code
|
||||
>curl -X GET
|
||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||
application/json"</code
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">WS</span>
|
||||
/api/v1/ws/<invoice_key></code
|
||||
>
|
||||
<h5
|
||||
class="text-caption q-mt-sm q-mb-none"
|
||||
v-text="$t('websocket_example')"
|
||||
></h5>
|
||||
<code
|
||||
>wscat -c <span v-text="websocketUrl"></span>/<span
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></span
|
||||
></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)/payments
|
||||
</h5>
|
||||
<code>{"balance": <int>, "payment": <object>}</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
|
||||
@@ -7,45 +7,78 @@
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div>
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" align="left">
|
||||
<q-tab
|
||||
name="user"
|
||||
:label="$t('account_settings')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('look_and_feel')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="api_acls"
|
||||
:label="$t('access_control_list')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn @click="updateAccount" unelevated color="primary">
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-tab-panels v-model="tab">
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs v-model="tab" vertical active-color="primary">
|
||||
<q-tab
|
||||
name="user"
|
||||
icon="person"
|
||||
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
|
||||
@update="val => tab = val.name"
|
||||
>
|
||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('account_settings')"></span
|
||||
></q-tooltip>
|
||||
</q-tab>
|
||||
|
||||
<q-tab
|
||||
name="notifications"
|
||||
icon="notifications"
|
||||
:label="$q.screen.gt.sm ? $t('notifications') : ''"
|
||||
@update="val => tab = val.name"
|
||||
>
|
||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('notifications')"></span
|
||||
></q-tooltip>
|
||||
</q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
icon="palette"
|
||||
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
|
||||
@update="val => tab = val.name"
|
||||
>
|
||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('look_and_feel')"></span
|
||||
></q-tooltip>
|
||||
</q-tab>
|
||||
<q-tab
|
||||
name="api_acls"
|
||||
icon="lock"
|
||||
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
|
||||
@update="val => tab = val.name"
|
||||
>
|
||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('access_control_list')"></span
|
||||
></q-tooltip>
|
||||
</q-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
<template v-slot:after>
|
||||
<q-scroll-area style="height: 80vh">
|
||||
<q-tab-panels v-if="user" v-model="tab">
|
||||
<q-tab-panel name="user">
|
||||
<div v-if="credentialsData.show">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h4 class="q-my-none">
|
||||
<span v-text="$t('password_config')"></span>
|
||||
<span v-text="$t('password')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -102,7 +135,7 @@
|
||||
unelevated
|
||||
color="primary"
|
||||
class="float-right"
|
||||
:label="$t('change_password')"
|
||||
:label="$t('update_password')"
|
||||
>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
@@ -110,7 +143,7 @@
|
||||
<q-card-section>
|
||||
<div class="col q-mb-sm">
|
||||
<h4 class="q-my-none">
|
||||
<span v-text="$t('pubkey')"></span>
|
||||
Nostr <span v-text="$t('pubkey')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<q-input
|
||||
@@ -272,22 +305,30 @@
|
||||
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>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<q-btn @click="updateAccount" unelevated color="primary">
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="showUpdateCredentials()"
|
||||
:label="$t('update_credentials')"
|
||||
:label="$t('change_password')"
|
||||
filled
|
||||
color="primary"
|
||||
class="float-right"
|
||||
@@ -335,95 +376,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="orange"
|
||||
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 +494,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 +509,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 +529,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 +541,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 +550,7 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="borderSelection"
|
||||
v-model="borderChoice"
|
||||
:options="borderOptions"
|
||||
label="Borders"
|
||||
@update:model-value="applyBorder"
|
||||
@@ -521,7 +578,7 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionSelection"
|
||||
v-model="reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="reactionChoiceFunc"
|
||||
@@ -533,6 +590,101 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="notifications">
|
||||
<q-card-section>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span
|
||||
v-text="$t('notifications_nostr_identifier')"
|
||||
></span>
|
||||
{%if not nostr_configured%}
|
||||
<br />
|
||||
<q-badge v-text="$t('not_connected')"></q-badge>
|
||||
{%endif%}
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="user.extra.notifications.nostr_identifier"
|
||||
:hint="$t('notifications_nostr_identifier_desc')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('notifications_chat_id')"></span>
|
||||
{%if not telegram_configured%}
|
||||
<br />
|
||||
<q-badge v-text="$t('not_connected')"></q-badge>
|
||||
{%endif%}
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="user.extra.notifications.telegram_chat_id"
|
||||
:hint="$t('notifications_chat_id_desc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-md"></q-separator>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('notification_outgoing_payment')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="user.extra.notifications.outgoing_payments_sats"
|
||||
:hint="$t('notification_outgoing_payment_desc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('notification_incoming_payment')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="user.extra.notifications.incoming_payments_sats"
|
||||
:hint="$t('notification_incoming_payment_desc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('exclude_wallets')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
v-model="user.extra.notifications.excluded_wallets"
|
||||
:options="g.user.walletOptions"
|
||||
:label="$t('exclude_wallets')"
|
||||
:hint="$t('notifications_excluded_wallets_desc')"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="api_acls">
|
||||
<div class="row q-mb-md">
|
||||
<q-badge v-if="user.admin">
|
||||
@@ -758,16 +910,9 @@
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else class="col-12 col-md-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
|
||||
</q-card-section>
|
||||
</q-scroll-area>
|
||||
</template>
|
||||
</q-splitter>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
|
||||
@@ -59,9 +59,6 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
|
||||
</script>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
@@ -87,18 +84,20 @@
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h5>
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TAGLINE}}
|
||||
</h6>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_DESCRIPTION}}
|
||||
</p>
|
||||
<template v-if="$q.screen.gt.sm">
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TAGLINE}}
|
||||
</h6>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_DESCRIPTION}}
|
||||
</p>
|
||||
</template>
|
||||
<!-- <div
|
||||
class="gt-sm"
|
||||
v-html="formatDescription"
|
||||
@@ -149,7 +148,7 @@
|
||||
<username-password
|
||||
v-if="authMethod != 'user-id-only'"
|
||||
:allowed_new_users="allowedRegister"
|
||||
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
|
||||
:auth-methods="LNBITS_AUTH_METHODS"
|
||||
:auth-action="authAction"
|
||||
v-model:user-name="username"
|
||||
v-model:password_1="password"
|
||||
@@ -167,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
|
||||
@@ -180,14 +180,15 @@
|
||||
>
|
||||
<span v-text="$t('new_user_not_allowed')"></span>
|
||||
</p>
|
||||
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
Aready have an account?
|
||||
<span
|
||||
class="text-secondary cursor-pointer"
|
||||
@click="showLogin('username-password')"
|
||||
>Login</span
|
||||
>
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click.prevent="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
@@ -223,7 +224,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width q-mb-lg q-mt-sm"
|
||||
>
|
||||
<div class="flex flex-center q-gutter-md q-py-md">
|
||||
@@ -249,7 +250,7 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE %}
|
||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="q-pt-md full-width">
|
||||
<div class="row justify-center q-mb-xl">
|
||||
<div class="full-width text-center">
|
||||
@@ -279,7 +280,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'" class="full-width">
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<div class="marquee">
|
||||
<div class="marquee__group">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
||||
%}
|
||||
<div class="row q-col-gutter-md">
|
||||
{% if HIDE_API and AD_SPACE %}
|
||||
{% if HIDE_API and AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
{% elif HIDE_API %}
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
@@ -31,7 +31,7 @@
|
||||
<q-card-section style="height: 130px">
|
||||
<div class="row q-gutter-md">
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
v-if="isSatsDenomination"
|
||||
class="col-1"
|
||||
style="max-width: 30px"
|
||||
>
|
||||
@@ -59,9 +59,11 @@
|
||||
<div class="col-7">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<div class="text-h3 q-my-none text-no-wrap">
|
||||
<div class="text-h3 q-my-none full-width">
|
||||
<strong
|
||||
v-text="walletFormatBalance(this.g.wallet.sat)"
|
||||
class="text-no-wrap"
|
||||
:style="{fontSize: 'clamp(0.75rem, 10vw, 3rem)', display: 'inline-block', maxWidth: '100%'}"
|
||||
></strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +168,12 @@
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('create_invoice')"
|
||||
></q-btn>
|
||||
<q-btn unelevated color="secondary" icon="qr_code_scanner">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
icon="qr_code_scanner"
|
||||
@click="showCamera"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('camera_tooltip')"></span
|
||||
></q-tooltip>
|
||||
@@ -204,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>
|
||||
@@ -235,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">
|
||||
@@ -461,7 +490,7 @@
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %} {% if AD_SPACE %}
|
||||
{% endif %} {% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
@@ -595,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
|
||||
@@ -616,13 +661,97 @@
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% endif %}
|
||||
|
||||
<q-input
|
||||
v-if="has_holdinvoice"
|
||||
filled
|
||||
dense
|
||||
v-model="receive.data.payment_hash"
|
||||
:label="$t('hold_invoice_payment_hash')"
|
||||
></q-input>
|
||||
<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
|
||||
@@ -632,7 +761,7 @@
|
||||
>
|
||||
<span
|
||||
v-if="receive.lnurl"
|
||||
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
|
||||
></span>
|
||||
<span v-else v-text="$t('create_invoice')"></span>
|
||||
</q-btn>
|
||||
@@ -656,7 +785,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>
|
||||
@@ -667,25 +803,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
|
||||
@@ -726,6 +864,13 @@
|
||||
</div>
|
||||
<div v-if="g.fiatTracking">
|
||||
<div v-if="isFiatPriority">
|
||||
<h5 class="q-my-none text-bold">
|
||||
<span
|
||||
v-text="walletFormatBalance(parse.invoice.sat)"
|
||||
></span>
|
||||
</h5>
|
||||
</div>
|
||||
<div v-else style="opacity: 0.75">
|
||||
<div class="text-h5 text-italic">
|
||||
<span
|
||||
v-text="parse.invoice.fiatAmount"
|
||||
@@ -733,17 +878,25 @@
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="opacity: 0.75">
|
||||
<h5 class="q-my-none text-bold">
|
||||
<span
|
||||
v-text="walletFormatBalance(parse.invoice.sat)"
|
||||
></span>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -937,7 +1090,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-mb-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
@@ -954,8 +1107,8 @@
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||
:mask="parse.data.unit == 'sat' ? '#' : ''"
|
||||
:step="parse.data.unit == 'sat' ? '1': '0.01'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
@@ -971,9 +1124,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">
|
||||
|
||||
@@ -127,7 +127,17 @@
|
||||
:label="props.row.wallet_count"
|
||||
@click="fetchWallets(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Wallets</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="(users.length == 1) && searchData.wallet_id"
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-sm"
|
||||
@click="showWalletPayments(searchData.wallet_id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import os
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from shutil import make_archive
|
||||
from pathlib import Path
|
||||
from shutil import make_archive, move
|
||||
from subprocess import Popen
|
||||
from typing import Optional
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import IO, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import filetype
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.misc import Image, SimpleStatus
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services import (
|
||||
enqueue_notification,
|
||||
enqueue_admin_notification,
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
)
|
||||
from lnbits.core.services.notifications import send_email_notification
|
||||
from lnbits.core.services.settings import dict_to_settings
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.helpers import safe_upload_file_path
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||
file_upload = File(...)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
@@ -50,6 +57,20 @@ async def api_monitor():
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/v1/testemail",
|
||||
name="TestEmail",
|
||||
description="send a test email to the admin",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_test_email():
|
||||
return await send_email_notification(
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
"This is a LNbits test email.",
|
||||
"LNbits Test Email",
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
@@ -63,10 +84,13 @@ async def api_get_settings(
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
enqueue_admin_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,8 +117,10 @@ async def api_reset_settings(field_name: str):
|
||||
|
||||
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
||||
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await delete_admin_settings()
|
||||
enqueue_admin_notification(
|
||||
NotificationType.settings_update, {"username": user.username}
|
||||
)
|
||||
await reset_core_settings()
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@@ -146,3 +172,93 @@ async def api_download_backup() -> FileResponse:
|
||||
return FileResponse(
|
||||
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/v1/images",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def upload_image(
|
||||
file: UploadFile = file_upload,
|
||||
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
|
||||
) -> Image:
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
|
||||
)
|
||||
|
||||
# validate file types
|
||||
file_info = filetype.guess(file.file)
|
||||
if file_info is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="Unable to determine file type",
|
||||
)
|
||||
detected_content_type = file_info.extension.lower()
|
||||
if (
|
||||
file.content_type not in settings.lnbits_upload_allowed_types
|
||||
or detected_content_type not in settings.lnbits_upload_allowed_types
|
||||
):
|
||||
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
|
||||
|
||||
# validate file name
|
||||
try:
|
||||
file_path = safe_upload_file_path(file.filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=f"The requested filename '{file.filename}' is forbidden.",
|
||||
) from e
|
||||
|
||||
# validate file size
|
||||
real_file_size = 0
|
||||
temp: IO = NamedTemporaryFile(delete=False)
|
||||
for chunk in file.file:
|
||||
real_file_size += len(chunk)
|
||||
if real_file_size > content_length:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File too large ({content_length / 1000} KB max)",
|
||||
)
|
||||
temp.write(chunk)
|
||||
temp.close()
|
||||
|
||||
move(temp.name, file_path)
|
||||
return Image(filename=file.filename)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/v1/images",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def list_uploaded_images() -> list[Image]:
|
||||
image_folder = Path(settings.lnbits_data_folder, "images")
|
||||
files = image_folder.glob("*")
|
||||
images = []
|
||||
for file in files:
|
||||
if file.is_file():
|
||||
images.append(Image(filename=file.name))
|
||||
return images
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/v1/images/{filename}",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_uploaded_image(filename: str) -> SimpleStatus:
|
||||
try:
|
||||
file_path = safe_upload_file_path(filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=f"The requested filename '{filename}' is forbidden.",
|
||||
) from e
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
|
||||
|
||||
file_path.unlink()
|
||||
return SimpleStatus(success=True, message=f"{filename} deleted")
|
||||
|
||||
+3
-150
@@ -1,36 +1,20 @@
|
||||
import hashlib
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from time import time
|
||||
from typing import Any
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
User,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
@@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import StatusResponse
|
||||
|
||||
from ..services import create_user_account, perform_lnurlauth
|
||||
from ..services import create_user_account
|
||||
|
||||
api_router = APIRouter(tags=["Core"])
|
||||
|
||||
@@ -92,137 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
return user.wallets[0]
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(
|
||||
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
url = str(lnurl_decode(code))
|
||||
domain = urlparse(url).netloc
|
||||
except Exception as exc:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
name, domain = name_domain
|
||||
url = (
|
||||
("http://" if domain.endswith(".onion") else "https://")
|
||||
+ domain
|
||||
+ "/.well-known/lnurlp/"
|
||||
+ name
|
||||
)
|
||||
# will proceed with these values
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
|
||||
) from exc
|
||||
|
||||
# params is what will be returned to the client
|
||||
params: dict = {"domain": domain}
|
||||
|
||||
if "tag=login" in url:
|
||||
params.update(kind="auth")
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
assert lnurlauth_key.verifying_key
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={"domain": domain, "message": "failed to get parameters"},
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(r.text)
|
||||
except json.decoder.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": f"got invalid response '{r.text[:200]}'",
|
||||
},
|
||||
) from exc
|
||||
|
||||
try:
|
||||
tag: str = data.get("tag")
|
||||
params.update(**data)
|
||||
if tag == "channelRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"kind": "channel",
|
||||
"message": "unsupported",
|
||||
},
|
||||
)
|
||||
elif tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
# callback with k1 already in it
|
||||
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||
qs: dict = parse_qs(parsed_callback.query)
|
||||
qs["k1"] = data["k1"]
|
||||
|
||||
# balanceCheck/balanceNotify
|
||||
if "balanceCheck" in data:
|
||||
params.update(balanceCheck=data["balanceCheck"])
|
||||
|
||||
# format callback url and send to client
|
||||
parsed_callback = parsed_callback._replace(
|
||||
query=urlencode(qs, doseq=True)
|
||||
)
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
elif tag == "payRequest":
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||
|
||||
params.update(
|
||||
description_hash=hashlib.sha256(
|
||||
data["metadata"].encode()
|
||||
).hexdigest()
|
||||
)
|
||||
metadata = json.loads(data["metadata"])
|
||||
for [k, v] in metadata:
|
||||
if k == "text/plain":
|
||||
params.update(description=v)
|
||||
if k in ("image/jpeg;base64", "image/png;base64"):
|
||||
data_uri = f"data:{k},{v}"
|
||||
params.update(image=data_uri)
|
||||
if k in ("text/email", "text/identifier"):
|
||||
params.update(targetUser=v)
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": f"lnurl JSON response invalid: {exc}",
|
||||
},
|
||||
) from exc
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(data.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/rate/history",
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
|
||||
@@ -74,7 +74,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
||||
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed."
|
||||
)
|
||||
account = await get_account_by_username_or_email(data.username)
|
||||
if not account or not account.verify_password(data.password):
|
||||
@@ -85,9 +85,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
@auth_router.post("/nostr", description="Login via Nostr")
|
||||
async def nostr_login(request: Request) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
|
||||
)
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||
event = _nostr_nip98_event(request)
|
||||
account = await get_account_by_pubkey(event["pubkey"])
|
||||
if not account:
|
||||
@@ -104,7 +102,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Login by 'User ID' not allowed.",
|
||||
)
|
||||
account = await get_account(data.usr)
|
||||
@@ -112,7 +110,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||
if account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
|
||||
HTTPStatus.FORBIDDEN, "Admin users cannot login with user id only."
|
||||
)
|
||||
return _auth_success_response(account.username, account.id, account.email)
|
||||
|
||||
@@ -193,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)
|
||||
@@ -225,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)
|
||||
@@ -242,7 +243,7 @@ async def login_with_sso_provider(
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
|
||||
@@ -257,7 +258,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
|
||||
@@ -285,7 +286,7 @@ async def logout() -> JSONResponse:
|
||||
async def register(data: RegisterUser) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Register by 'Username and Password' not allowed.",
|
||||
)
|
||||
|
||||
@@ -320,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 (
|
||||
@@ -328,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:
|
||||
@@ -346,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
|
||||
@@ -355,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)
|
||||
@@ -375,11 +380,13 @@ async def update_password(
|
||||
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
assert data.password == data.password_repeat, "Passwords do not match."
|
||||
assert data.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()
|
||||
@@ -387,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)
|
||||
|
||||
@@ -413,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:
|
||||
@@ -437,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
|
||||
|
||||
@@ -449,7 +448,7 @@ async def update(
|
||||
@auth_router.put("/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
|
||||
account = await get_account(settings.super_user)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||
@@ -472,10 +471,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
|
||||
|
||||
if verified_user_id:
|
||||
if account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
|
||||
account = await get_account(verified_user_id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
|
||||
redirect_path = "/account"
|
||||
|
||||
if account:
|
||||
@@ -499,9 +498,12 @@ def _auth_success_response(
|
||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||
)
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -521,9 +523,12 @@ def _auth_api_token_response(
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
@@ -565,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}'.")
|
||||
|
||||
@@ -574,38 +579,50 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
||||
def _nostr_nip98_event(request: Request) -> dict:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr Auth header missing.")
|
||||
scheme, token = auth_header.split()
|
||||
if scheme.lower() != "nostr":
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
|
||||
event = None
|
||||
try:
|
||||
event_json = base64.b64decode(token.encode("ascii"))
|
||||
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,6 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Optional, Union
|
||||
from typing import Annotated, Optional, Union
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -37,7 +36,7 @@ generic_router = APIRouter(
|
||||
|
||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||
async def favicon():
|
||||
return FileResponse(Path("lnbits", "static", "favicon.ico"))
|
||||
return RedirectResponse(settings.lnbits_qr_logo)
|
||||
|
||||
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
@@ -71,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()
|
||||
@@ -207,9 +206,32 @@ async def account(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
nostr_configured = settings.is_nostr_notifications_configured()
|
||||
telegram_configured = settings.is_telegram_notifications_configured()
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/account.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"nostr_configured": nostr_configured,
|
||||
"telegram_configured": telegram_configured,
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/wallets",
|
||||
response_class=HTMLResponse,
|
||||
description="show wallets page",
|
||||
)
|
||||
async def wallets(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallets.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
@@ -323,7 +345,6 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
||||
"node/index.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
@@ -343,7 +364,6 @@ async def node_public(request: Request):
|
||||
request,
|
||||
"node/public.html",
|
||||
{
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
@@ -362,7 +382,6 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
|
||||
"admin/index.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
@@ -380,7 +399,6 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"settings": settings.dict(),
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
@@ -403,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",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from lnurl import (
|
||||
LnurlResponseException,
|
||||
LnurlSuccessResponse,
|
||||
)
|
||||
from lnurl import execute_login as lnurlauth
|
||||
from lnurl import execute_withdraw as lnurl_withdraw
|
||||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import (
|
||||
LnurlAuthResponse,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayResponse,
|
||||
LnurlResponseModel,
|
||||
LnurlWithdrawResponse,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import CreateLnurlWithdraw, Payment
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..services import fetch_lnurl_pay_request, pay_invoice
|
||||
|
||||
lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurlscan/{code}",
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
| LnurlAuthResponse
|
||||
| LnurlErrorResponse,
|
||||
)
|
||||
async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
||||
try:
|
||||
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
|
||||
except LnurlResponseException as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
|
||||
check_callback_url(res.callback)
|
||||
return res
|
||||
|
||||
|
||||
@lnurl_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> LnurlResponseModel:
|
||||
check_callback_url(data.callback)
|
||||
try:
|
||||
res = await lnurlauth(
|
||||
res=data,
|
||||
seed=key_type.wallet.adminkey,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
return res
|
||||
except LnurlResponseException as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@lnurl_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
try:
|
||||
res = await fetch_lnurl_pay_request(data=data)
|
||||
except LnurlResponseException as exc:
|
||||
logger.warning(exc)
|
||||
msg = f"Failed to connect to {data.res.callback}."
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
|
||||
|
||||
extra: dict[str, Any] = {}
|
||||
if res.success_action:
|
||||
extra["success_action"] = res.success_action.json()
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=str(res.pr),
|
||||
description=data.res.metadata.text,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@lnurl_router.post(
|
||||
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_payment_pay_with_nfc(
|
||||
payment_request: str,
|
||||
lnurl_data: CreateLnurlWithdraw,
|
||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
||||
try:
|
||||
res = await lnurl_handle(
|
||||
lnurl_data.lnurl_w.callback_url, user_agent=settings.user_agent, timeout=10
|
||||
)
|
||||
except (LnurlResponseException, Exception) as exc:
|
||||
logger.warning(exc)
|
||||
return LnurlErrorResponse(reason=str(exc))
|
||||
|
||||
if not isinstance(res, LnurlWithdrawResponse):
|
||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
||||
try:
|
||||
check_callback_url(res.callback)
|
||||
except ValueError as exc:
|
||||
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
|
||||
|
||||
try:
|
||||
res2 = await lnurl_withdraw(
|
||||
res, payment_request, user_agent=settings.user_agent, timeout=10
|
||||
)
|
||||
except (LnurlResponseException, Exception) as exc:
|
||||
logger.warning(exc)
|
||||
return LnurlErrorResponse(reason=str(exc))
|
||||
if not isinstance(res2, (LnurlSuccessResponse, LnurlErrorResponse)):
|
||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
||||
|
||||
return res2
|
||||
@@ -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
|
||||
@@ -7,8 +7,9 @@ from pydantic import BaseModel
|
||||
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import Feature
|
||||
|
||||
from ...db import Filters, Page
|
||||
from ...nodes.base import (
|
||||
@@ -26,9 +27,13 @@ from ...nodes.base import (
|
||||
from ...utils.cache import cache
|
||||
|
||||
|
||||
def require_node():
|
||||
node_class = get_node_class()
|
||||
if not node_class:
|
||||
def require_node() -> Node:
|
||||
funding_source = get_funding_source()
|
||||
if (
|
||||
not funding_source.features
|
||||
or Feature.nodemanager not in funding_source.features
|
||||
or not funding_source.__node_cls__
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||
detail="Active backend does not implement Node API",
|
||||
@@ -38,7 +43,7 @@ def require_node():
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
return node_class
|
||||
return funding_source.__node_cls__(funding_source)
|
||||
|
||||
|
||||
def check_public():
|
||||
@@ -91,7 +96,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 +126,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 +175,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 +217,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"
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import json
|
||||
import ssl
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -14,7 +10,7 @@ from fastapi import (
|
||||
Query,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from lnurl import decode as lnurl_decode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud.payments import (
|
||||
@@ -22,11 +18,10 @@ from lnbits.core.crud.payments import (
|
||||
get_wallets_stats,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
DecodePayment,
|
||||
KeyType,
|
||||
PayLnurlWData,
|
||||
Payment,
|
||||
PaymentCountField,
|
||||
PaymentCountStat,
|
||||
@@ -34,30 +29,22 @@ from lnbits.core.models import (
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentWalletStats,
|
||||
Wallet,
|
||||
SettleInvoice,
|
||||
)
|
||||
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,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
check_callback_url,
|
||||
filter_dict_keys,
|
||||
generate_filter_params_openapi,
|
||||
)
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
from lnbits.wallets.base import InvoiceResponse
|
||||
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
@@ -68,9 +55,13 @@ from ..crud import (
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from ..services import (
|
||||
create_invoice,
|
||||
cancel_hold_invoice,
|
||||
create_payment_request,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
settle_hold_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
)
|
||||
|
||||
@@ -82,7 +73,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 +92,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 +107,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.UNAUTHORIZED,
|
||||
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 +187,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",
|
||||
@@ -264,13 +194,21 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_all_payments_paginated(
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
|
||||
return await get_payments_paginated(
|
||||
filters=filters,
|
||||
user_id=for_user_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -296,6 +234,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(
|
||||
@@ -303,21 +242,21 @@ 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.UNAUTHORIZED,
|
||||
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.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
@@ -334,79 +273,6 @@ 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
|
||||
|
||||
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(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.HTTPError, ssl.SSLError) as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) from exc
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
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}."
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
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"
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@payment_router.get("/{payment_hash}")
|
||||
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
@@ -427,6 +293,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:
|
||||
@@ -462,57 +331,32 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
|
||||
async def api_payment_pay_with_nfc(
|
||||
payment_request: str,
|
||||
lnurl_data: PayLnurlWData,
|
||||
) -> JSONResponse:
|
||||
lnurl = lnurl_data.lnurl_w.lower()
|
||||
@payment_router.post("/settle")
|
||||
async def api_payments_settle(
|
||||
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> InvoiceResponse:
|
||||
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, incoming=True, wallet_id=key_type.wallet.id
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Payment does not exist or does not belong to this wallet.",
|
||||
)
|
||||
return await settle_hold_invoice(payment, data.preimage)
|
||||
|
||||
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
||||
url = lnurl.replace("lnurlw://", "https://")
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
check_callback_url(url)
|
||||
lnurl_req = await client.get(url, timeout=10)
|
||||
if lnurl_req.is_error:
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Error loading LNURL request"}
|
||||
)
|
||||
|
||||
lnurl_res = lnurl_req.json()
|
||||
|
||||
if lnurl_res.get("status") == "ERROR":
|
||||
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
|
||||
|
||||
if lnurl_res.get("tag") != "withdrawRequest":
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Invalid LNURL-withdraw"}
|
||||
)
|
||||
|
||||
callback_url = lnurl_res["callback"]
|
||||
k1 = lnurl_res["k1"]
|
||||
|
||||
callback_req = await client.get(
|
||||
callback_url,
|
||||
params={"k1": k1, "pr": payment_request},
|
||||
timeout=10,
|
||||
)
|
||||
if callback_req.is_error:
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Error loading callback request"}
|
||||
)
|
||||
|
||||
callback_res = callback_req.json()
|
||||
|
||||
if callback_res.get("status") == "ERROR":
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": callback_res["reason"]}
|
||||
)
|
||||
else:
|
||||
return JSONResponse({"success": True, "detail": callback_res})
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
|
||||
@payment_router.post("/cancel")
|
||||
async def api_payments_cancel(
|
||||
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> InvoiceResponse:
|
||||
payment = await get_standalone_payment(
|
||||
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Payment does not exist or does not belong to this wallet.",
|
||||
)
|
||||
return await cancel_hold_invoice(payment)
|
||||
|
||||
@@ -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
|
||||
@@ -35,7 +35,7 @@ from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services import (
|
||||
create_user_account_no_ckeck,
|
||||
enqueue_notification,
|
||||
enqueue_admin_notification,
|
||||
update_user_account,
|
||||
update_user_extensions,
|
||||
update_wallet_balance,
|
||||
@@ -100,20 +100,29 @@ 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()
|
||||
account.hash_password(data.password)
|
||||
user = await create_user_account_no_ckeck(account)
|
||||
user = await create_user_account_no_ckeck(account, default_exts=data.extensions)
|
||||
data.id = user.id
|
||||
return data
|
||||
|
||||
|
||||
@users_router.put("/user/{user_id}", name="Update user")
|
||||
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
|
||||
async def api_update_user(
|
||||
user_id: str, data: CreateUser, user: User = Depends(check_admin)
|
||||
) -> CreateUser:
|
||||
if user_id != data.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
|
||||
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
)
|
||||
|
||||
if data.password or data.password_repeat:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
||||
@@ -124,6 +133,7 @@ async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra or UserExtra(),
|
||||
)
|
||||
await update_user_account(account)
|
||||
@@ -140,7 +150,7 @@ async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
|
||||
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,
|
||||
@@ -177,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}"
|
||||
|
||||
@@ -206,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)
|
||||
|
||||
|
||||
@@ -249,19 +260,50 @@ 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",
|
||||
summary="First time it is called it does a soft delete (only sets a flag)."
|
||||
"The second time it is called will delete the entry from the DB",
|
||||
)
|
||||
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
|
||||
async def api_users_delete_user_wallet(
|
||||
user_id: str, wallet: str, user: User = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet does not exist.",
|
||||
)
|
||||
|
||||
if user_id == settings.super_user and user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action only allowed for super user.",
|
||||
)
|
||||
|
||||
if wal.deleted:
|
||||
await force_delete_wallet(wallet)
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
||||
@@ -279,7 +321,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
|
||||
if not wallet:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
|
||||
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.balance_update,
|
||||
{
|
||||
"amount": int(data.amount),
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-11
@@ -252,7 +252,7 @@ class Connection(Compat):
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
parsed_values,
|
||||
)
|
||||
row = result.mappings().first()
|
||||
@@ -554,9 +554,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model and self.model.__search_fields__:
|
||||
where_stmts.append(
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
||||
# Use `COALESCE` to handle `NULL` values and `||`
|
||||
# for cross-database compatible string concatenation
|
||||
_fields = self.model.__search_fields__
|
||||
search_expr = " || ".join(
|
||||
f"COALESCE(CAST({field} AS TEXT), '')" for field in _fields
|
||||
)
|
||||
where_stmts.append(f"lower({search_expr}) LIKE :search")
|
||||
|
||||
if where_stmts:
|
||||
return "WHERE " + " AND ".join(where_stmts)
|
||||
@@ -576,7 +580,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for key, value in page_filter.values.items():
|
||||
values[key] = value
|
||||
if self.search and self.model:
|
||||
values["search"] = f"%{self.search}%"
|
||||
values["search"] = f"%{self.search.lower()}%"
|
||||
return values
|
||||
|
||||
|
||||
@@ -593,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(
|
||||
@@ -611,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:
|
||||
@@ -644,9 +648,9 @@ def model_to_dict(model: BaseModel) -> dict:
|
||||
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||
if isinstance(value, str):
|
||||
if value == "null":
|
||||
if value == "null" or value == "":
|
||||
return None
|
||||
_subdict = json.loads(value)
|
||||
_subdict = _safe_load_json(value)
|
||||
elif isinstance(value, dict):
|
||||
_subdict = value
|
||||
|
||||
@@ -654,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
|
||||
@@ -670,7 +674,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
type_ = model.__fields__[key].type_
|
||||
outertype_ = model.__fields__[key].outer_type_
|
||||
if get_origin(outertype_) is list:
|
||||
_items = json.loads(value) if isinstance(value, str) else value
|
||||
_items = _safe_load_json(value) if isinstance(value, str) else value
|
||||
_dict[key] = [
|
||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||
for v in _items
|
||||
@@ -691,7 +695,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
# TODO: remove this when all sub models are migrated to Pydantic
|
||||
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
||||
if type_ is dict and value:
|
||||
_dict[key] = json.loads(value)
|
||||
_dict[key] = _safe_load_json(value)
|
||||
continue
|
||||
_dict[key] = value
|
||||
continue
|
||||
@@ -699,3 +703,12 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
if isinstance(_model, BaseModel):
|
||||
_model.__init__(**_dict) # type: ignore
|
||||
return _model
|
||||
|
||||
|
||||
def _safe_load_json(value: str) -> dict:
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# DB is corrupted if it gets here
|
||||
logger.error(f"Failed to decode JSON: '{value}'")
|
||||
return {}
|
||||
|
||||
+32
-11
@@ -1,11 +1,11 @@
|
||||
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
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, HTTPBearer, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
@@ -28,11 +28,18 @@ 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(tokenUrl="api/v1/auth", auto_error=False)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="api/v1/auth",
|
||||
auto_error=False,
|
||||
description="OAuth2 access token for authentication with username and password.",
|
||||
)
|
||||
http_bearer = HTTPBearer(
|
||||
auto_error=False,
|
||||
description="Bearer Token for custom ACL based access control",
|
||||
)
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-KEY",
|
||||
auto_error=False,
|
||||
@@ -95,7 +102,7 @@ class KeyChecker(SecurityBase):
|
||||
request.scope["user_id"] = wallet.user
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
@@ -132,8 +139,9 @@ async def require_invoice_key(
|
||||
async def check_access_token(
|
||||
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
|
||||
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
|
||||
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
|
||||
) -> Optional[str]:
|
||||
return header_access_token or cookie_access_token
|
||||
return header_access_token or cookie_access_token or bearer_access_token
|
||||
|
||||
|
||||
async def check_user_exists(
|
||||
@@ -157,7 +165,7 @@ async def check_user_exists(
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
if not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
if not user:
|
||||
@@ -193,7 +201,7 @@ async def access_token_payload(
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
@@ -206,7 +214,7 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
@@ -215,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
|
||||
@@ -338,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,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from enum import Enum
|
||||
|
||||
from loguru import logger
|
||||
|
||||
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 | None:
|
||||
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
|
||||
_provider = _init_fiat_provider(FiatProviderType[name])
|
||||
if not _provider:
|
||||
return None
|
||||
|
||||
fiat_providers[name] = _provider
|
||||
return fiat_providers[name]
|
||||
|
||||
|
||||
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider | None:
|
||||
if not settings.is_fiat_provider_enabled(fiat_provider.name):
|
||||
logger.warning(f"Fiat provider '{fiat_provider.name}' not enabled.")
|
||||
return None
|
||||
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)]
|
||||
)
|
||||
+97
-69
@@ -3,21 +3,19 @@ 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
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi import Request
|
||||
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
|
||||
|
||||
@@ -42,7 +40,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}&"
|
||||
@@ -65,59 +63,53 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
|
||||
if settings.lnbits_ad_space_enabled:
|
||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||
t.env.globals["AD_SPACE_TITLE"] = settings.lnbits_ad_space_title
|
||||
window_settings = {
|
||||
"AD_SPACE": settings.lnbits_ad_space.split(","),
|
||||
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
|
||||
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
|
||||
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
|
||||
"HIDE_API": settings.lnbits_hide_api,
|
||||
"SITE_TITLE": settings.lnbits_site_title,
|
||||
"SITE_TAGLINE": settings.lnbits_site_tagline,
|
||||
"SITE_DESCRIPTION": settings.lnbits_site_description,
|
||||
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
||||
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
||||
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
|
||||
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
|
||||
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
|
||||
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
|
||||
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
||||
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
||||
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
|
||||
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
|
||||
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
|
||||
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
|
||||
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
|
||||
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
|
||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||
"LNBITS_VERSION": settings.version,
|
||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||
"LNBITS_DENOMINATION": (
|
||||
settings.lnbits_denomination
|
||||
if settings.lnbits_denomination == "FakeWallet"
|
||||
else "sats"
|
||||
),
|
||||
"has_holdinvoice": settings.has_holdinvoice,
|
||||
}
|
||||
|
||||
t.env.globals["VOIDWALLET"] = settings.lnbits_backend_wallet_class == "VoidWallet"
|
||||
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
|
||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
|
||||
settings.lnbits_show_home_page_elements
|
||||
)
|
||||
t.env.globals["LNBITS_CUSTOM_IMAGE"] = settings.lnbits_custom_image
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
||||
settings.lnbits_extensions_deactivate_all
|
||||
)
|
||||
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
|
||||
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids)
|
||||
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.lnbits_default_reaction:
|
||||
t.env.globals["USE_DEFAULT_REACTION"] = settings.lnbits_default_reaction
|
||||
|
||||
if settings.lnbits_default_theme:
|
||||
t.env.globals["USE_DEFAULT_THEME"] = settings.lnbits_default_theme
|
||||
|
||||
if settings.lnbits_default_border:
|
||||
t.env.globals["USE_DEFAULT_BORDER"] = settings.lnbits_default_border
|
||||
|
||||
if settings.lnbits_default_gradient:
|
||||
t.env.globals["USE_DEFAULT_GRADIENT"] = settings.lnbits_default_gradient
|
||||
|
||||
if settings.lnbits_default_bgimage:
|
||||
t.env.globals["USE_DEFAULT_BGIMAGE"] = settings.lnbits_default_bgimage
|
||||
t.env.globals["WINDOW_SETTINGS"] = window_settings
|
||||
for key, value in window_settings.items():
|
||||
t.env.globals[key] = value
|
||||
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
@@ -131,8 +123,6 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
||||
|
||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -163,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)
|
||||
@@ -207,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
|
||||
@@ -291,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())
|
||||
|
||||
@@ -346,5 +356,23 @@ def normalize_path(path: Optional[str]) -> str:
|
||||
return "/" + "/".join(path_segments(path))
|
||||
|
||||
|
||||
def normalized_path(request: Request) -> str:
|
||||
return "/" + "/".join(path_segments(request.url.path))
|
||||
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
|
||||
image_folder = Path(settings.lnbits_data_folder, directory)
|
||||
file_path = image_folder / filename
|
||||
# Prevent dir traversal attack
|
||||
if image_folder.resolve() not in file_path.resolve().parents:
|
||||
raise ValueError("Unsafe filename.")
|
||||
# Prevent filename with subdirectories
|
||||
file_path = image_folder / filename.split("/")[-1]
|
||||
return file_path.resolve()
|
||||
|
||||
|
||||
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
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,15 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from .base import Node
|
||||
|
||||
|
||||
def get_node_class() -> Optional[Node]:
|
||||
return NODE
|
||||
|
||||
|
||||
def set_node_class(node: Node):
|
||||
global NODE
|
||||
NODE = node
|
||||
|
||||
|
||||
NODE: Optional[Node] = None
|
||||
|
||||
@@ -11,12 +11,12 @@ from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.nodes import Node
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
Node,
|
||||
NodeChannel,
|
||||
NodeFees,
|
||||
NodeInfoResponse,
|
||||
|
||||
@@ -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()
|
||||
+202
-77
@@ -4,16 +4,16 @@ import importlib
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from hashlib import sha256
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from time import gmtime, strftime, time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
|
||||
|
||||
@@ -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,12 +256,12 @@ 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=[])
|
||||
lnbits_default_accounting_currency: str | None = Field(default=None)
|
||||
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
|
||||
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
|
||||
lnbits_default_reaction: str = Field(default="confettiBothSides")
|
||||
lnbits_default_theme: str = Field(default="salvador")
|
||||
lnbits_default_border: str = Field(default="hard-border")
|
||||
@@ -271,13 +272,29 @@ class ThemesSettings(LNbitsSettings):
|
||||
class OpsSettings(LNbitsSettings):
|
||||
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
|
||||
lnbits_upload_allowed_types: list[str] = Field(
|
||||
default=[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/heics",
|
||||
"png",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"heic",
|
||||
"heif",
|
||||
"heics",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FeeSettings(LNbitsSettings):
|
||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||
lnbits_service_fee: float = Field(default=0)
|
||||
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
|
||||
lnbits_reserve_fee_percent: float = Field(default=1.0, ge=0)
|
||||
lnbits_service_fee: float = Field(default=0, ge=0)
|
||||
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
||||
lnbits_service_fee_max: int = Field(default=0)
|
||||
lnbits_service_fee_wallet: str | None = Field(default=None)
|
||||
@@ -289,13 +306,13 @@ 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):
|
||||
lnbits_exchange_rate_cache_seconds: int = Field(default=30)
|
||||
lnbits_exchange_history_size: int = Field(default=60)
|
||||
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300)
|
||||
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
|
||||
lnbits_exchange_history_size: int = Field(default=60, ge=0)
|
||||
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
|
||||
|
||||
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
|
||||
default=[
|
||||
@@ -360,24 +377,24 @@ class ExchangeProvidersSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class SecuritySettings(LNbitsSettings):
|
||||
lnbits_rate_limit_no: str = Field(default="200")
|
||||
lnbits_rate_limit_no: int = Field(default=200, ge=0)
|
||||
lnbits_rate_limit_unit: str = Field(default="minute")
|
||||
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)
|
||||
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
|
||||
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
|
||||
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
||||
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0, ge=0)
|
||||
lnbits_wallet_limit_secs_between_trans: int = Field(default=0, ge=0)
|
||||
lnbits_only_allow_incoming_payments: bool = Field(default=False)
|
||||
lnbits_watchdog_switch_to_voidwallet: bool = Field(default=False)
|
||||
lnbits_watchdog_interval_minutes: int = Field(default=60)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||
lnbits_watchdog_interval_minutes: int = Field(default=60, gt=0)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000, gt=0)
|
||||
|
||||
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000)
|
||||
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000)
|
||||
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
|
||||
def is_wallet_max_balance_exceeded(self, amount):
|
||||
return (
|
||||
@@ -396,6 +413,7 @@ class NotificationsSettings(LNbitsSettings):
|
||||
lnbits_telegram_notifications_chat_id: str = Field(default="")
|
||||
lnbits_email_notifications_enabled: bool = Field(default=False)
|
||||
lnbits_email_notifications_email: str = Field(default="")
|
||||
lnbits_email_notifications_username: str = Field(default="")
|
||||
lnbits_email_notifications_password: str = Field(default="")
|
||||
lnbits_email_notifications_server: str = Field(default="smtp.protonmail.ch")
|
||||
lnbits_email_notifications_port: int = Field(default=587)
|
||||
@@ -406,13 +424,30 @@ class NotificationsSettings(LNbitsSettings):
|
||||
notification_balance_delta_changed: bool = Field(default=True)
|
||||
lnbits_notification_server_start_stop: bool = Field(default=True)
|
||||
lnbits_notification_watchdog: bool = Field(default=False)
|
||||
lnbits_notification_server_status_hours: int = Field(default=24)
|
||||
lnbits_notification_incoming_payment_amount_sats: int = Field(default=1_000_000)
|
||||
lnbits_notification_outgoing_payment_amount_sats: int = Field(default=1_000_000)
|
||||
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
|
||||
lnbits_notification_incoming_payment_amount_sats: int = Field(
|
||||
default=1_000_000, ge=0
|
||||
)
|
||||
lnbits_notification_outgoing_payment_amount_sats: int = Field(
|
||||
default=1_000_000, ge=0
|
||||
)
|
||||
|
||||
def is_nostr_notifications_configured(self) -> bool:
|
||||
return (
|
||||
self.lnbits_nostr_notifications_enabled
|
||||
and self.lnbits_nostr_notifications_private_key is not None
|
||||
)
|
||||
|
||||
def is_telegram_notifications_configured(self) -> bool:
|
||||
return (
|
||||
self.lnbits_telegram_notifications_enabled
|
||||
and self.lnbits_telegram_notifications_access_token is not None
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
|
||||
class LNbitsFundingSource(LNbitsSettings):
|
||||
@@ -426,6 +461,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")
|
||||
@@ -527,21 +574,65 @@ 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):
|
||||
lightning_invoice_expiry: int = Field(default=3600)
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
|
||||
class FundingSourcesSettings(
|
||||
FakeWalletFundingSource,
|
||||
LNbitsFundingSource,
|
||||
ClicheFundingSource,
|
||||
CLNRestFundingSource,
|
||||
CoreLightningFundingSource,
|
||||
CoreLightningRestFundingSource,
|
||||
EclairFundingSource,
|
||||
@@ -558,11 +649,47 @@ 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
|
||||
# It will not fail the payment, it will make it return pending after the timeout
|
||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5)
|
||||
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):
|
||||
@@ -582,7 +709,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"
|
||||
@@ -601,7 +728,7 @@ class AuthMethods(Enum):
|
||||
|
||||
|
||||
class AuthSettings(LNbitsSettings):
|
||||
auth_token_expire_minutes: int = Field(default=525600)
|
||||
auth_token_expire_minutes: int = Field(default=525600, gt=0)
|
||||
auth_all_methods = [a.value for a in AuthMethods]
|
||||
auth_allowed_methods: list[str] = Field(
|
||||
default=[
|
||||
@@ -611,7 +738,7 @@ class AuthSettings(LNbitsSettings):
|
||||
)
|
||||
# How many seconds after login the user is allowed to update its credentials.
|
||||
# A fresh login is required afterwards.
|
||||
auth_credetials_update_threshold: int = Field(default=120)
|
||||
auth_credetials_update_threshold: int = Field(default=120, gt=0)
|
||||
|
||||
def is_auth_method_allowed(self, method: AuthMethods):
|
||||
return method.value in self.auth_allowed_methods
|
||||
@@ -637,13 +764,15 @@ 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):
|
||||
lnbits_audit_enabled: bool = Field(default=True)
|
||||
|
||||
# number of days to keep the audit entry
|
||||
lnbits_audit_retention_days: int = Field(default=7)
|
||||
lnbits_audit_retention_days: int = Field(default=7, ge=0)
|
||||
|
||||
lnbits_audit_log_ip_address: bool = Field(default=False)
|
||||
lnbits_audit_log_path_params: bool = Field(default=True)
|
||||
@@ -735,6 +864,7 @@ class EditableSettings(
|
||||
SecuritySettings,
|
||||
NotificationsSettings,
|
||||
FundingSourcesSettings,
|
||||
FiatProvidersSettings,
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
@@ -780,7 +910,7 @@ class EnvSettings(LNbitsSettings):
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000)
|
||||
port: int = Field(default=5000, gt=0)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
lnbits_title: str = Field(default="LNbits API")
|
||||
lnbits_path: str = Field(default=".")
|
||||
@@ -792,24 +922,27 @@ class EnvSettings(LNbitsSettings):
|
||||
enable_log_to_file: bool = Field(default=True)
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
cleanup_wallets_days: int = Field(default=90)
|
||||
funding_source_max_retries: int = Field(default=4)
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
return self.lnbits_extensions_path == "lnbits"
|
||||
|
||||
@property
|
||||
def lnbits_server_up_time(self) -> str:
|
||||
up_time = int(time() - self.server_startup_time)
|
||||
return strftime("%H:%M:%S", gmtime(up_time))
|
||||
|
||||
|
||||
class SaaSSettings(LNbitsSettings):
|
||||
lnbits_saas_callback: str | None = Field(default=None)
|
||||
lnbits_saas_secret: str | None = Field(default=None)
|
||||
lnbits_saas_instance_id: str | None = Field(default=None)
|
||||
def check_auth_secret_key(self):
|
||||
if self.auth_secret_key:
|
||||
return
|
||||
if not os.path.isdir(settings.lnbits_data_folder):
|
||||
os.mkdir(settings.lnbits_data_folder)
|
||||
auth_key_file = Path(settings.lnbits_data_folder, ".lnbits_auth_key")
|
||||
if auth_key_file.is_file():
|
||||
with open(auth_key_file) as file:
|
||||
self.auth_secret_key = file.readline()
|
||||
return
|
||||
self.auth_secret_key = uuid4().hex
|
||||
with open(auth_key_file, "w+") as file:
|
||||
file.write(self.auth_secret_key)
|
||||
|
||||
|
||||
class PersistenceSettings(LNbitsSettings):
|
||||
@@ -824,6 +957,8 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
"BreezLiquidSdkWallet",
|
||||
"CLNRestWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
@@ -838,6 +973,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
"StrikeWallet",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -861,6 +997,16 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
|
||||
|
||||
lnbits_all_extensions_ids: set[str] = Field(default=[])
|
||||
|
||||
server_startup_time: int = Field(default=time())
|
||||
|
||||
has_holdinvoice: bool = Field(default=False)
|
||||
has_nodemanager: bool = Field(default=False)
|
||||
|
||||
@property
|
||||
def lnbits_server_up_time(self) -> str:
|
||||
up_time = int(time() - self.server_startup_time)
|
||||
return strftime("%H:%M:%S", gmtime(up_time))
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||
@@ -869,7 +1015,6 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
|
||||
class ReadOnlySettings(
|
||||
EnvSettings,
|
||||
ExtensionsInstallSettings,
|
||||
SaaSSettings,
|
||||
PersistenceSettings,
|
||||
SuperUserSettings,
|
||||
):
|
||||
@@ -919,6 +1064,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
|
||||
@@ -948,31 +1099,6 @@ def set_cli_settings(**kwargs):
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def send_admin_user_to_saas():
|
||||
if settings.lnbits_saas_callback:
|
||||
with httpx.Client() as client:
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-API-KEY": settings.lnbits_saas_secret,
|
||||
}
|
||||
payload = {
|
||||
"instance_id": settings.lnbits_saas_instance_id,
|
||||
"adminuser": settings.super_user,
|
||||
}
|
||||
try:
|
||||
client.post(
|
||||
settings.lnbits_saas_callback,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
logger.success("sent super_user to saas application")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"error sending super_user to saas:"
|
||||
f" {settings.lnbits_saas_callback}. Error: {e!s}"
|
||||
)
|
||||
|
||||
|
||||
readonly_variables = ReadOnlySettings.readonly_fields()
|
||||
transient_variables = TransientSettings.readonly_fields()
|
||||
|
||||
@@ -981,9 +1107,8 @@ settings = Settings()
|
||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||
|
||||
settings.version = importlib.metadata.version("lnbits")
|
||||
settings.auth_secret_key = (
|
||||
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
)
|
||||
|
||||
settings.check_auth_secret_key()
|
||||
|
||||
if not settings.user_agent:
|
||||
settings.user_agent = f"LNbits/{settings.version}"
|
||||
|
||||
+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
+13
-11
File diff suppressed because one or more lines are too long
+173
-572
@@ -1,617 +1,218 @@
|
||||
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[data-theme=salvador].neon-border .q-card,
|
||||
body[data-theme=salvador].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=salvador].neon-border .q-date,
|
||||
body[data-theme=salvador].neon-border .q-date--dark {
|
||||
border: 2px solid #1976d2;
|
||||
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: #462f36;
|
||||
}
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
|
||||
background: #47393d !important;
|
||||
}
|
||||
|
||||
body[data-theme=bitcoin].body--light {
|
||||
background-color: #f4f4f4;
|
||||
body[data-theme=cyber] {
|
||||
--q-primary: #7cb342;
|
||||
--q-secondary: #558b2f;
|
||||
--q-dark-page: #000;
|
||||
}
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
|
||||
background: #1f2915 !important;
|
||||
}
|
||||
|
||||
[data-theme=bitcoin] .bg-primary {
|
||||
background: #ff9853 !important;
|
||||
body[data-theme=mint] {
|
||||
--q-primary: #3ab77d;
|
||||
--q-secondary: #27b065;
|
||||
--q-dark-page: #1f342b;
|
||||
}
|
||||
[data-theme=bitcoin] .text-primary {
|
||||
color: #ff9853 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .bg-secondary {
|
||||
background: #ff7353 !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-secondary {
|
||||
color: #ff7353 !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: #2d293b !important;
|
||||
}
|
||||
[data-theme=bitcoin] .text-marginal-bg {
|
||||
color: #2d293b !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 {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=freedom].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
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 {
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=cyber].body--light {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
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[data-theme=salvador] {
|
||||
--q-primary: #1976d2;
|
||||
--q-secondary: #26a69a;
|
||||
--q-dark-page: #253647;
|
||||
}
|
||||
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
|
||||
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
|
||||
background: #343d47 !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;
|
||||
}
|
||||
|
||||
[data-theme=monochrome] .bg-primary {
|
||||
background: #494949 !important;
|
||||
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;
|
||||
}
|
||||
[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;
|
||||
body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
[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] {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -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',
|
||||
|
||||
+112
-21
@@ -22,11 +22,13 @@ 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',
|
||||
close: 'Close',
|
||||
restart: 'Restart server',
|
||||
image_library: 'Image Library',
|
||||
save: 'Save',
|
||||
save_tooltip: 'Save your changes',
|
||||
credit_debit: 'Credit / Debit',
|
||||
@@ -48,14 +50,19 @@ 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',
|
||||
exclude_wallets: 'Exclude Wallets',
|
||||
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',
|
||||
@@ -64,9 +71,9 @@ window.localisation.en = {
|
||||
donate: 'Donate',
|
||||
view_github: 'View on GitHub',
|
||||
voidwallet_active: 'VoidWallet is active! Payments disabled',
|
||||
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
|
||||
service_fee: 'Service fee: {amount} % per transaction',
|
||||
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
|
||||
service_fee_badge: 'Service fee: {amount} % per transaction',
|
||||
service_fee_max_badge:
|
||||
'Service fee: {amount} % per transaction (max {max} {denom})',
|
||||
service_fee_tooltip:
|
||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
@@ -95,6 +102,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!',
|
||||
@@ -116,7 +128,7 @@ window.localisation.en = {
|
||||
copy_wallet_url: 'Copy wallet URL',
|
||||
disclaimer_dialog_title: 'Important!',
|
||||
disclaimer_dialog:
|
||||
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
|
||||
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nLNbits holds no responsibility for loss of access to funds.',
|
||||
no_transactions: 'No transactions made yet',
|
||||
manage: 'Manage',
|
||||
exchanges: 'Exchanges',
|
||||
@@ -124,6 +136,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:
|
||||
@@ -136,6 +149,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',
|
||||
@@ -163,18 +177,50 @@ window.localisation.en = {
|
||||
extension_required_lnbits_version: 'This release requires LNbits version',
|
||||
min_version: 'Minimum (included)',
|
||||
max_version: 'Maximum (excluded)',
|
||||
preimage: 'Preimage',
|
||||
preimage_hint: 'Preimage to settle the hold invoice',
|
||||
hold_invoice: 'Hold Invoice',
|
||||
hold_invoice_description:
|
||||
'This invoice is on hold and requires a preimage to settle.',
|
||||
payment_hash: 'Payment Hash',
|
||||
invoice_cancelled: 'Invoice Cancelled',
|
||||
invoice_settled: 'Invoice Settled',
|
||||
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
|
||||
settle_invoice: 'Settle Invoice',
|
||||
cancel_invoice: 'Cancel Invoice',
|
||||
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!',
|
||||
funding_sources: 'Funding Sources',
|
||||
latest_update: 'You are on the latest version {version}.',
|
||||
notifications: 'Notifications',
|
||||
notifications_configure: 'Configure Notifications',
|
||||
@@ -184,6 +230,9 @@ window.localisation.en = {
|
||||
notifications_nostr_private_key: 'Nostr Private Key',
|
||||
notifications_nostr_private_key_desc:
|
||||
'Private key (hex or nsec) to sign the messages sent to Nostr',
|
||||
notifications_nostr_identifier: 'Nostr Identifier',
|
||||
notifications_nostr_identifier_desc:
|
||||
'Nip5 identifier to send notifications to',
|
||||
notifications_nostr_identifiers: 'Nostr Identifiers',
|
||||
notifications_nostr_identifiers_desc:
|
||||
'List of identifiers to send notifications to',
|
||||
@@ -193,14 +242,19 @@ window.localisation.en = {
|
||||
notifications_enable_telegram_desc: 'Send notfications over Telegram',
|
||||
notifications_telegram_access_token: 'Access Token',
|
||||
notifications_telegram_access_token_desc: 'Access token for the bot',
|
||||
notifications_chat_id: 'Chat ID',
|
||||
notifications_chat_id_desc: 'Chat ID to send the notifications to',
|
||||
|
||||
notifications_chat_id: 'Telegram Chat ID',
|
||||
notifications_chat_id_desc: 'Telegram Chat ID to send the notifications to',
|
||||
notifications_excluded_wallets_desc:
|
||||
'Do not send notifications for these wallets',
|
||||
notifications_email_config: 'Email Configuration',
|
||||
notifications_enable_email: 'Enable Email',
|
||||
notifications_enable_email_desc: 'Send notfications over Email',
|
||||
notifications_enable_email_desc: 'Send notifications over email',
|
||||
notifications_send_test_email: 'Send test email',
|
||||
notifications_send_email: 'Send email',
|
||||
notifications_send_email_desc: 'Email you will send from',
|
||||
notifications_send_email_username: 'Username',
|
||||
notifications_send_email_username_desc:
|
||||
'Username, will use the email if not set',
|
||||
notifications_send_email_password: 'Send email password',
|
||||
notifications_send_email_password_desc:
|
||||
'Password for the email you will send from',
|
||||
@@ -258,6 +312,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',
|
||||
@@ -272,7 +327,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',
|
||||
@@ -297,6 +352,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',
|
||||
@@ -305,21 +363,28 @@ 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',
|
||||
password_repeat: 'Password repeat',
|
||||
update_password: 'Update Password',
|
||||
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.',
|
||||
reset_key_copy: 'Click OK to copy the reset URL to your clipboard.',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
username: 'Username',
|
||||
pubkey: 'Public Key',
|
||||
user_id: 'User ID',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
first_name: 'First Name',
|
||||
last_name: 'Last Name',
|
||||
@@ -329,11 +394,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',
|
||||
@@ -345,11 +414,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',
|
||||
@@ -362,6 +434,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',
|
||||
@@ -423,7 +498,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',
|
||||
@@ -435,7 +510,7 @@ window.localisation.en = {
|
||||
fee_reserve_percent: 'Fee Reserve Percent',
|
||||
fee_reserve_msats: 'Reserve fee in msats',
|
||||
reserve_fee_in_percent: 'Reserve fee in percent',
|
||||
payment_wait_time: 'Payment Wait Time',
|
||||
payment_wait_time: 'Payment Wait Time (sec)',
|
||||
payment_wait_time_desc:
|
||||
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
|
||||
server_management: 'Server Management',
|
||||
@@ -461,12 +536,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',
|
||||
@@ -488,9 +567,11 @@ window.localisation.en = {
|
||||
'Disable Service Fee for Internal Lightning Payments',
|
||||
ui_management: 'UI Management',
|
||||
ui_site_title: 'Site Title',
|
||||
ui_changing_remove_lnbits_elements:
|
||||
' (changing will remove LNbits elements on the homepage and footer)',
|
||||
ui_site_tagline: 'Site Tagline',
|
||||
ui_elements_enable: 'Enable elements on homepage',
|
||||
ui_elements_disable: 'Disable elements on homepage',
|
||||
ui_elements_enable: 'Enable elements on homepage/footer',
|
||||
ui_elements_disable: 'Disable elements on homepage/footer',
|
||||
ui_toggle_elements_tip: "Remove homepage elements like 'runs on' etc",
|
||||
ui_site_description: 'Site Description',
|
||||
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
|
||||
@@ -500,14 +581,13 @@ window.localisation.en = {
|
||||
denomination: 'Denomination',
|
||||
denomination_hint: 'The name for the FakeWallet token',
|
||||
denomination_error: 'Denomination must be 3 characters, or `sats`',
|
||||
ui_qr_code_logo: 'QR Code Logo',
|
||||
ui_qr_code_logo_hint: 'URL to logo image in QR code',
|
||||
ui_qr_code_logo: 'QR Code/Favicon Logo',
|
||||
ui_qr_code_logo_hint: 'QR code and favicon logo url',
|
||||
ui_custom_image: 'Custom Image',
|
||||
ui_custom_image_label: 'URL to custom image',
|
||||
ui_custom_image_hint: 'Image showed at homepage/login',
|
||||
ui_custom_badge: 'Custom Badge',
|
||||
ui_custom_badge_label:
|
||||
"Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'",
|
||||
ui_custom_badge_label: "Custom Badge 'USE WITH CAUTION'",
|
||||
ui_custom_badge_color_label: 'Custom Badge Color',
|
||||
themes: 'Themes',
|
||||
themes_hint: 'Choose themes available for users',
|
||||
@@ -527,6 +607,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',
|
||||
@@ -560,5 +641,15 @@ window.localisation.en = {
|
||||
view_list: 'View wallets as list',
|
||||
view_column: 'View wallets as rows',
|
||||
filter_payments: 'Filter payments',
|
||||
filter_date: 'Filter by date'
|
||||
filter_date: 'Filter by date',
|
||||
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',
|
||||
connected: 'Connected',
|
||||
not_connected: 'Not Connected'
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user