Compare commits
19
Commits
v1.1.0
...
v1.2.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aeecd4dc0 | ||
|
|
e750165cc3 | ||
|
|
77906bc817 | ||
|
|
63e728710d | ||
|
|
27fd510142 | ||
|
|
e6de66e1b1 | ||
|
|
4071925f65 | ||
|
|
3c4d186dba | ||
|
|
56aebb9d8f | ||
|
|
beee24bd92 | ||
|
|
375b95c004 | ||
|
|
5345ccaf4e | ||
|
|
efc5233399 | ||
|
|
e4d09c6d12 | ||
|
|
7d0545dae1 | ||
|
|
00fccf513e | ||
|
|
34b8490a2d | ||
|
|
cbbba5c4c7 | ||
|
|
4a0ef7fa1a |
+9
-3
@@ -40,7 +40,7 @@ PORT=5000
|
|||||||
######################################
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
# which fundingsources are allowed in the admin ui
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
@@ -105,6 +105,10 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
|||||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||||
BOLTZ_CLIENT_WALLET="lnbits"
|
BOLTZ_CLIENT_WALLET="lnbits"
|
||||||
|
|
||||||
|
# StrikeWallet
|
||||||
|
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||||
|
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||||
|
|
||||||
# ZBDWallet
|
# ZBDWallet
|
||||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
@@ -163,14 +167,16 @@ GITHUB_CLIENT_SECRET=""
|
|||||||
KEYCLOAK_CLIENT_ID=""
|
KEYCLOAK_CLIENT_ID=""
|
||||||
KEYCLOAK_CLIENT_SECRET=""
|
KEYCLOAK_CLIENT_SECRET=""
|
||||||
KEYCLOAK_DISCOVERY_URL=""
|
KEYCLOAK_DISCOVERY_URL=""
|
||||||
|
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||||
|
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||||
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
# uvicorn variable, uncomment to allow https behind a proxy
|
# uvicorn variable, allow https behind a proxy
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
# FORWARDED_ALLOW_IPS="*"
|
FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
|
|||||||
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
||||||
export LNBITS_DATA_FOLDER
|
export LNBITS_DATA_FOLDER
|
||||||
export LNBITS_EXTENSIONS_PATH
|
export LNBITS_EXTENSIONS_PATH
|
||||||
export LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
# Define the LNbits URL
|
# Define the LNbits URL
|
||||||
URL="http://0.0.0.0:5000"
|
URL="http://0.0.0.0:5000"
|
||||||
|
|||||||
+1
-1
@@ -55,4 +55,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
|
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# LNbits Funding Sources Comparison Table
|
||||||
|
|
||||||
|
LNbits can use a number of different Lightning Network funding source.
|
||||||
|
|
||||||
|
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
|
||||||
|
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
|
||||||
|
|
||||||
|
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
|
||||||
|
|
||||||
|
## LNbits Lightning Network Funding Sources Comparison Table
|
||||||
|
|
||||||
|
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||||
|
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
|
||||||
|
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||||
|
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||||
|
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||||
|
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||||
|
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||||
|
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||||
|
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||||
|
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||||
|
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||||
|
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||||
|
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||||
|
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||||
|
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||||
|
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||||
|
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||||
|
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||||
|
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||||
@@ -18,7 +18,7 @@ Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppI
|
|||||||
sudo apt-get install libfuse2
|
sudo apt-get install libfuse2
|
||||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||||
chmod +x LNbits-latest.AppImage
|
chmod +x LNbits-latest.AppImage
|
||||||
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
|
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||||
```
|
```
|
||||||
|
|
||||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ LNbits can run on top of many Lightning Network funding sources with more being
|
|||||||
|
|
||||||
A backend wallet can be configured using the following LNbits environment variables:
|
A backend wallet can be configured using the following LNbits environment variables:
|
||||||
|
|
||||||
|
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||||
|
|
||||||
### CoreLightning
|
### CoreLightning
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
|
|||||||
+10
-4
@@ -518,12 +518,15 @@ def encrypt_macaroon():
|
|||||||
|
|
||||||
@encrypt.command("aes")
|
@encrypt.command("aes")
|
||||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||||
def encrypt_aes(payload: str):
|
@click.option(
|
||||||
|
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
||||||
|
)
|
||||||
|
def encrypt_aes(payload: str, urlsafe: bool = False):
|
||||||
"""AES encrypts a payload"""
|
"""AES encrypts a payload"""
|
||||||
key = getpass("Enter encryption key: ")
|
key = getpass("Enter encryption key: ")
|
||||||
aes = AESCipher(key.encode())
|
aes = AESCipher(key.encode())
|
||||||
try:
|
try:
|
||||||
encrypted = aes.encrypt(payload.encode())
|
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
click.echo(f"Error encrypting payload: {ex}")
|
click.echo(f"Error encrypting payload: {ex}")
|
||||||
return
|
return
|
||||||
@@ -533,12 +536,15 @@ def encrypt_aes(payload: str):
|
|||||||
|
|
||||||
@decrypt.command("aes")
|
@decrypt.command("aes")
|
||||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||||
def decrypt_aes(payload: str):
|
@click.option(
|
||||||
|
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
||||||
|
)
|
||||||
|
def decrypt_aes(payload: str, urlsafe: bool = False):
|
||||||
"""AES decrypts a payload"""
|
"""AES decrypts a payload"""
|
||||||
key = getpass("Enter encryption key: ")
|
key = getpass("Enter encryption key: ")
|
||||||
aes = AESCipher(key.encode())
|
aes = AESCipher(key.encode())
|
||||||
try:
|
try:
|
||||||
decrypted = aes.decrypt(payload)
|
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
click.echo(f"Error decrypting payload: {ex}")
|
click.echo(f"Error decrypting payload: {ex}")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -123,12 +123,8 @@ async def get_payments_paginated(
|
|||||||
values["wallet_id"] = wallet_id
|
values["wallet_id"] = wallet_id
|
||||||
clause.append("wallet_id = :wallet_id")
|
clause.append("wallet_id = :wallet_id")
|
||||||
elif user_id:
|
elif user_id:
|
||||||
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
"no-wallets-for-user"
|
clause.append(only_user_wallets)
|
||||||
]
|
|
||||||
# wallet ids are safe to use in sql queries
|
|
||||||
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
|
||||||
clause.append(f""" wallet_id IN ({", ".join(wallet_ids_str)}) """)
|
|
||||||
|
|
||||||
if complete and pending:
|
if complete and pending:
|
||||||
clause.append(
|
clause.append(
|
||||||
@@ -366,12 +362,19 @@ async def get_payments_history(
|
|||||||
async def get_payment_count_stats(
|
async def get_payment_count_stats(
|
||||||
field: PaymentCountField,
|
field: PaymentCountField,
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[PaymentCountStat]:
|
) -> list[PaymentCountStat]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where()
|
extra_stmts = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
extra_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
|
clause = filters.where(extra_stmts)
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT {field} as field, count(*) as total
|
SELECT {field} as field, count(*) as total
|
||||||
@@ -389,18 +392,26 @@ async def get_payment_count_stats(
|
|||||||
|
|
||||||
async def get_daily_stats(
|
async def get_daily_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
|
|
||||||
in_clause = filters.where(
|
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||||
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
out_where_stmts = [
|
||||||
)
|
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
|
||||||
out_clause = filters.where(
|
]
|
||||||
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
|
|
||||||
)
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
in_where_stmts.append(only_user_wallets)
|
||||||
|
out_where_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
|
in_clause = filters.where(in_where_stmts)
|
||||||
|
out_clause = filters.where(out_where_stmts)
|
||||||
|
|
||||||
date_trunc = db.datetime_grouping("day")
|
date_trunc = db.datetime_grouping("day")
|
||||||
query = """
|
query = """
|
||||||
SELECT {date_trunc} date,
|
SELECT {date_trunc} date,
|
||||||
@@ -431,6 +442,7 @@ async def get_daily_stats(
|
|||||||
|
|
||||||
async def get_wallets_stats(
|
async def get_wallets_stats(
|
||||||
filters: Optional[Filters[PaymentFilters]] = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[PaymentWalletStats]:
|
) -> list[PaymentWalletStats]:
|
||||||
|
|
||||||
@@ -449,6 +461,10 @@ async def get_wallets_stats(
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
if user_id:
|
||||||
|
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||||
|
where_stmts.append(only_user_wallets)
|
||||||
|
|
||||||
clauses = filters.where(where_stmts)
|
clauses = filters.where(where_stmts)
|
||||||
|
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
@@ -524,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
|||||||
""",
|
""",
|
||||||
{"status": status, "hash": payment_hash},
|
{"status": status, "hash": payment_hash},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _only_user_wallets_statement(
|
||||||
|
user_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> str:
|
||||||
|
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
||||||
|
"no-wallets-for-user"
|
||||||
|
]
|
||||||
|
# wallet ids are safe to use in sql queries
|
||||||
|
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
||||||
|
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from typing import Optional
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.db import Connection
|
from lnbits.core.models.wallets import WalletsFilters
|
||||||
|
from lnbits.db import Connection, Filters, Page
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..models import Wallet
|
from ..models import Wallet
|
||||||
@@ -135,6 +136,29 @@ async def get_wallets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wallets_paginated(
|
||||||
|
user_id: str,
|
||||||
|
deleted: Optional[bool] = None,
|
||||||
|
filters: Optional[Filters[WalletsFilters]] = None,
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
|
) -> Page[Wallet]:
|
||||||
|
if deleted is None:
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||||
|
return await (conn or db).fetch_page(
|
||||||
|
"""
|
||||||
|
SELECT *, COALESCE((
|
||||||
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
|
), 0) AS balance_msat FROM wallets
|
||||||
|
""",
|
||||||
|
where=where,
|
||||||
|
values={"user": user_id, "deleted": deleted},
|
||||||
|
filters=filters,
|
||||||
|
model=Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallets_ids(
|
async def get_wallets_ids(
|
||||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class UserExtra(BaseModel):
|
|||||||
# - "google | github | ...": the user was created using an SSO provider
|
# - "google | github | ...": the user was created using an SSO provider
|
||||||
provider: str | None = "lnbits" # auth provider
|
provider: str | None = "lnbits" # auth provider
|
||||||
|
|
||||||
|
# how many wallets are shown in the user interface
|
||||||
|
visible_wallet_count: int | None = 10
|
||||||
|
|
||||||
|
|
||||||
class EndpointAccess(BaseModel):
|
class EndpointAccess(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from enum import Enum
|
|||||||
from ecdsa import SECP256k1, SigningKey
|
from ecdsa import SECP256k1, SigningKey
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.lnurl import encode as lnurl_encode
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
|
|||||||
class WalletExtra(BaseModel):
|
class WalletExtra(BaseModel):
|
||||||
icon: str = "flash_on"
|
icon: str = "flash_on"
|
||||||
color: str = "primary"
|
color: str = "primary"
|
||||||
|
pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Wallet(BaseModel):
|
class Wallet(BaseModel):
|
||||||
@@ -83,3 +85,13 @@ class KeyType(Enum):
|
|||||||
class WalletTypeInfo:
|
class WalletTypeInfo:
|
||||||
key_type: KeyType
|
key_type: KeyType
|
||||||
wallet: Wallet
|
wallet: Wallet
|
||||||
|
|
||||||
|
|
||||||
|
class WalletsFilters(FilterModel):
|
||||||
|
__search_fields__ = ["id", "name", "currency"]
|
||||||
|
|
||||||
|
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
id: str | None
|
||||||
|
name: str | None
|
||||||
|
currency: str | None
|
||||||
|
|||||||
@@ -84,8 +84,12 @@ async def pay_invoice(
|
|||||||
|
|
||||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||||
|
|
||||||
|
service_fee_memo = f"""
|
||||||
|
Service fee for payment of {abs(payment.sat)} sats.
|
||||||
|
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||||
|
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||||
await _credit_service_fee_wallet(payment, new_conn)
|
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
@@ -399,8 +403,9 @@ async def check_transaction_status(
|
|||||||
|
|
||||||
async def get_payments_daily_stats(
|
async def get_payments_daily_stats(
|
||||||
filters: Filters[PaymentFilters],
|
filters: Filters[PaymentFilters],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
) -> list[PaymentDailyStats]:
|
) -> list[PaymentDailyStats]:
|
||||||
data_in, data_out = await get_daily_stats(filters)
|
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
||||||
balance_total: float = 0
|
balance_total: float = 0
|
||||||
|
|
||||||
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
||||||
@@ -704,7 +709,7 @@ def _validate_payment_request(
|
|||||||
|
|
||||||
|
|
||||||
async def _credit_service_fee_wallet(
|
async def _credit_service_fee_wallet(
|
||||||
payment: Payment, conn: Optional[Connection] = None
|
payment: Payment, memo: str, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||||
@@ -715,7 +720,7 @@ async def _credit_service_fee_wallet(
|
|||||||
bolt11=payment.bolt11,
|
bolt11=payment.bolt11,
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
amount_msat=abs(service_fee_msat),
|
amount_msat=abs(service_fee_msat),
|
||||||
memo="Service fee",
|
memo=memo,
|
||||||
)
|
)
|
||||||
await create_payment(
|
await create_payment(
|
||||||
checking_id=f"service_fee_{payment.payment_hash}",
|
checking_id=f"service_fee_{payment.payment_hash}",
|
||||||
|
|||||||
@@ -132,8 +132,8 @@
|
|||||||
>
|
>
|
||||||
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
<div class="col-md-4 col-sm-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.keycloak_discovery_url"
|
v-model="formData.keycloak_discovery_url"
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
<div class="col-md-4 col-sm-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.keycloak_client_id"
|
v-model="formData.keycloak_client_id"
|
||||||
@@ -159,6 +159,22 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_custom_org"
|
||||||
|
:label="$t('auth_keycloak_custom_org_label')"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_custom_icon"
|
||||||
|
:label="$t('auth_keycloak_custom_icon_label')"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|||||||
@@ -171,23 +171,26 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
|
|
||||||
<template v-slot:after>
|
<template v-slot:after>
|
||||||
<q-form name="settings_form" id="settings_form">
|
<q-form name="settings_form" id="settings_form">
|
||||||
<q-tab-panels
|
<q-scroll-area style="height: 100vh">
|
||||||
v-model="tab"
|
<q-tab-panels
|
||||||
animated
|
v-model="tab"
|
||||||
swipeable
|
animated
|
||||||
vertical
|
swipeable
|
||||||
transition-prev="jump-up"
|
vertical
|
||||||
transition-next="jump-up"
|
scroll
|
||||||
>
|
transition-prev="jump-up"
|
||||||
{% include "admin/_tab_funding.html" %} {% include
|
transition-next="jump-up"
|
||||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
>
|
||||||
{% include "admin/_tab_exchange_providers.html" %} {% include
|
{% include "admin/_tab_funding.html" %} {% include
|
||||||
"admin/_tab_extensions.html" %} {% include
|
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||||
"admin/_tab_notifications.html" %} {% include
|
%} {% include "admin/_tab_exchange_providers.html" %} {% include
|
||||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
"admin/_tab_extensions.html" %} {% include
|
||||||
%}{% include "admin/_tab_audit.html"%}{% include
|
"admin/_tab_notifications.html" %} {% include
|
||||||
"admin/_tab_library.html"%}
|
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||||
</q-tab-panels>
|
%}{% include "admin/_tab_audit.html"%}{% include
|
||||||
|
"admin/_tab_library.html"%}
|
||||||
|
</q-tab-panels>
|
||||||
|
</q-scroll-area>
|
||||||
</q-form>
|
</q-form>
|
||||||
</template>
|
</template>
|
||||||
</q-splitter>
|
</q-splitter>
|
||||||
|
|||||||
@@ -335,6 +335,22 @@
|
|||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<span v-text="$t('visible_wallet_count')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
v-model="user.extra.visible_wallet_count"
|
||||||
|
:label="$t('visible_wallet_count')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
class="q-mb-md"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row q-mb-md">
|
<div class="row q-mb-md">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<span v-text="$t('color_scheme')"></span>
|
<span v-text="$t('color_scheme')"></span>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
<span v-text="$t('existing_account_question')"></span>
|
<span v-text="$t('existing_account_question')"></span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="text-secondary cursor-pointer"
|
class="text-secondary cursor-pointer q-ml-sm"
|
||||||
@click="showLogin('username-password')"
|
@click="showLogin('username-password')"
|
||||||
v-text="$t('login')"
|
v-text="$t('login')"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -242,6 +242,23 @@
|
|||||||
{{ SITE_TITLE }} Wallet:
|
{{ SITE_TITLE }} Wallet:
|
||||||
<strong><em v-text="g.wallet.name"></em></strong>
|
<strong><em v-text="g.wallet.name"></em></strong>
|
||||||
</div>
|
</div>
|
||||||
|
<q-space></q-space>
|
||||||
|
<div class="float-right">
|
||||||
|
<q-btn
|
||||||
|
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
|
||||||
|
round
|
||||||
|
class="float-right"
|
||||||
|
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
|
||||||
|
text-color="black"
|
||||||
|
size="sm"
|
||||||
|
icon="push_pin"
|
||||||
|
style="transform: rotate(30deg)"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
><span v-text="$t('pin_wallet')"></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -15,19 +15,36 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="activeWallet.show">
|
<div v-else-if="activeWallet.show">
|
||||||
<div class="row q-mb-lg">
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
<div class="col">
|
<div class="col-12">
|
||||||
<q-btn
|
<q-card>
|
||||||
icon="arrow_back_ios"
|
<div class="q-pa-sm">
|
||||||
@click="backToUsersPage()"
|
<div class="row">
|
||||||
:label="$t('back')"
|
<div class="q-pa-xs">
|
||||||
></q-btn>
|
<q-btn
|
||||||
<q-btn
|
icon="arrow_back_ios"
|
||||||
@click="createWalletDialog.show = true"
|
@click="backToUsersPage()"
|
||||||
:label="$t('create_new_wallet')"
|
:label="$t('back')"
|
||||||
color="primary"
|
></q-btn>
|
||||||
class="q-ml-md"
|
</div>
|
||||||
></q-btn>
|
<div class="q-pa-xs">
|
||||||
|
<q-btn
|
||||||
|
@click="createWalletDialog.show = true"
|
||||||
|
:label="$t('create_new_wallet')"
|
||||||
|
color="primary"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="q-pa-xs">
|
||||||
|
<q-btn
|
||||||
|
@click="deleteAllUserWallets(activeWallet.userId)"
|
||||||
|
:label="$t('delete_all_wallets')"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-card class="q-pa-md">
|
<q-card class="q-pa-md">
|
||||||
|
|||||||
@@ -216,6 +216,25 @@ async def account(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get(
|
||||||
|
"/wallets",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
description="show wallets page",
|
||||||
|
)
|
||||||
|
async def wallets(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
):
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
request,
|
||||||
|
"core/wallets.html",
|
||||||
|
{
|
||||||
|
"user": user.json(),
|
||||||
|
"ajax": _is_ajax_request(request),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get("/service-worker.js")
|
@generic_router.get("/service-worker.js")
|
||||||
async def service_worker(request: Request):
|
async def service_worker(request: Request):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
@@ -402,7 +421,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
|
|
||||||
|
|
||||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||||
async def payments_index(request: Request, user: User = Depends(check_admin)):
|
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"payments/index.html",
|
"payments/index.html",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -212,7 +212,10 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
|
|||||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||||
try:
|
try:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["noderank"]
|
data = r.json()
|
||||||
|
if "noderank" not in data:
|
||||||
|
return None
|
||||||
|
return data["noderank"]
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ from lnbits.core.services.payments import (
|
|||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
check_admin,
|
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
@@ -116,30 +115,44 @@ async def api_payments_history(
|
|||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/count",
|
"/stats/count",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
dependencies=[Depends(check_admin)],
|
|
||||||
response_model=List[PaymentCountStat],
|
response_model=List[PaymentCountStat],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_counting_stats(
|
async def api_payments_counting_stats(
|
||||||
count_by: PaymentCountField = Query("tag"),
|
count_by: PaymentCountField = Query("tag"),
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
|
||||||
return await get_payment_count_stats(count_by, filters)
|
if user.admin:
|
||||||
|
# admin user can see payments from all wallets
|
||||||
|
for_user_id = None
|
||||||
|
else:
|
||||||
|
# regular user can only see payments from their wallets
|
||||||
|
for_user_id = user.id
|
||||||
|
|
||||||
|
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/wallets",
|
"/stats/wallets",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
dependencies=[Depends(check_admin)],
|
|
||||||
response_model=List[PaymentWalletStats],
|
response_model=List[PaymentWalletStats],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_wallets_stats(
|
async def api_payments_wallets_stats(
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
|
||||||
return await get_wallets_stats(filters)
|
if user.admin:
|
||||||
|
# admin user can see payments from all wallets
|
||||||
|
for_user_id = None
|
||||||
|
else:
|
||||||
|
# regular user can only see payments from their wallets
|
||||||
|
for_user_id = user.id
|
||||||
|
|
||||||
|
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
@@ -153,22 +166,13 @@ async def api_payments_daily_stats(
|
|||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
|
|
||||||
if not user.admin:
|
if user.admin:
|
||||||
exc = HTTPException(
|
# admin user can see payments from all wallets
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
for_user_id = None
|
||||||
detail="Missing wallet id.",
|
else:
|
||||||
)
|
# regular user can only see payments from their wallets
|
||||||
wallet_filter = next(
|
for_user_id = user.id
|
||||||
(f for f in filters.filters if f.field == "wallet_id"), None
|
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
||||||
)
|
|
||||||
if not wallet_filter:
|
|
||||||
raise exc
|
|
||||||
wallet_id = list((wallet_filter.values or {}).values())
|
|
||||||
if len(wallet_id) == 0:
|
|
||||||
raise exc
|
|
||||||
if not user.get_wallet(wallet_id[0]):
|
|
||||||
raise exc
|
|
||||||
return await get_payments_daily_stats(filters)
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ async def api_update_user(
|
|||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, user: User = Depends(check_admin)
|
user_id: str, user: User = Depends(check_admin)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
wallets = await get_wallets(user_id)
|
wallets = await get_wallets(user_id, deleted=False)
|
||||||
if len(wallets) > 0:
|
if len(wallets) > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -257,6 +257,28 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
|
|||||||
return SimpleStatus(success=True, message="Wallet is already active.")
|
return SimpleStatus(success=True, message="Wallet is already active.")
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.delete(
|
||||||
|
"/user/{user_id}/wallets",
|
||||||
|
name="Delete all wallets for user",
|
||||||
|
summary="Soft delete (only sets a flag) all user wallets.",
|
||||||
|
)
|
||||||
|
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
|
||||||
|
if user_id == settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Action not allowed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
wallets = await get_wallets(user_id, deleted=False)
|
||||||
|
for wallet in wallets:
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||||
|
|
||||||
|
return SimpleStatus(
|
||||||
|
success=True,
|
||||||
|
message=f"Deleted '{len(wallets)}' wallets. ",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@users_router.delete(
|
@users_router.delete(
|
||||||
"/user/{user_id}/wallet/{wallet}",
|
"/user/{user_id}/wallet/{wallet}",
|
||||||
name="Delete wallet by id",
|
name="Delete wallet by id",
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ from fastapi import (
|
|||||||
HTTPException,
|
HTTPException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
||||||
|
from lnbits.core.models.wallets import WalletsFilters
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
from lnbits.helpers import generate_filter_params_openapi
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@wallet_router.get(
|
||||||
|
"/paginated",
|
||||||
|
name="Wallet List",
|
||||||
|
summary="get paginated list of user wallets",
|
||||||
|
response_description="list of user wallets",
|
||||||
|
response_model=Page[Wallet],
|
||||||
|
openapi_extra=generate_filter_params_openapi(WalletsFilters),
|
||||||
|
)
|
||||||
|
async def api_wallets_paginated(
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
filters: Filters = Depends(parse_filters(WalletsFilters)),
|
||||||
|
):
|
||||||
|
page = await get_wallets_paginated(
|
||||||
|
user_id=user.id,
|
||||||
|
filters=filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.put("/{new_name}")
|
@wallet_router.put("/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||||
@@ -74,6 +99,7 @@ async def api_update_wallet(
|
|||||||
icon: Optional[str] = Body(None),
|
icon: Optional[str] = Body(None),
|
||||||
color: Optional[str] = Body(None),
|
color: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
|
pinned: Optional[bool] = Body(None),
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
@@ -82,6 +108,7 @@ async def api_update_wallet(
|
|||||||
wallet.name = name or wallet.name
|
wallet.name = name or wallet.name
|
||||||
wallet.extra.icon = icon or wallet.extra.icon
|
wallet.extra.icon = icon or wallet.extra.icon
|
||||||
wallet.extra.color = color or wallet.extra.color
|
wallet.extra.color = color or wallet.extra.color
|
||||||
|
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||||
wallet.currency = currency if currency is not None else wallet.currency
|
wallet.currency = currency if currency is not None else wallet.currency
|
||||||
await update_wallet(wallet)
|
await update_wallet(wallet)
|
||||||
return wallet
|
return wallet
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from shortuuid import uuid
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -71,10 +72,11 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
async def exception_handler(request: Request, exc: Exception):
|
async def exception_handler(request: Request, exc: Exception):
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
traceback.print_exception(etype, exc, tb)
|
traceback.print_exception(etype, exc, tb)
|
||||||
logger.error(f"Exception: {exc!s}")
|
exception_id = uuid()
|
||||||
|
logger.error(f"Exception ID: {exception_id}\n{exc!s}")
|
||||||
return render_html_error(request, exc) or JSONResponse(
|
return render_html_error(request, exc) or JSONResponse(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
content={"detail": str(exc)},
|
content={"detail": f"Unexpected error! ID: {exception_id}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(AssertionError)
|
@app.exception_handler(AssertionError)
|
||||||
|
|||||||
+17
-5
@@ -11,6 +11,7 @@ import jinja2
|
|||||||
import jwt
|
import jwt
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
from loguru import logger
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
|||||||
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
||||||
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
||||||
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
|
"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_IMAGE": settings.lnbits_custom_image,
|
||||||
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
||||||
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
||||||
@@ -279,12 +282,21 @@ def is_lnbits_version_ok(
|
|||||||
|
|
||||||
|
|
||||||
def check_callback_url(url: str):
|
def check_callback_url(url: str):
|
||||||
netloc = urlparse(url).netloc
|
if not settings.lnbits_callback_url_rules:
|
||||||
|
# no rules, all urls are allowed
|
||||||
|
return
|
||||||
|
u = urlparse(url)
|
||||||
for rule in settings.lnbits_callback_url_rules:
|
for rule in settings.lnbits_callback_url_rules:
|
||||||
if re.match(rule, netloc) is None:
|
try:
|
||||||
raise ValueError(
|
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
|
||||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
return
|
||||||
)
|
except re.error:
|
||||||
|
logger.debug(f"Invalid regex rule: '{rule}'. ")
|
||||||
|
continue
|
||||||
|
raise ValueError(
|
||||||
|
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
|
||||||
|
f"Please check your admin settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
def download_url(url, save_path):
|
||||||
|
|||||||
+12
-1
@@ -381,7 +381,7 @@ class SecuritySettings(LNbitsSettings):
|
|||||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||||
lnbits_callback_url_rules: list[str] = Field(
|
lnbits_callback_url_rules: list[str] = Field(
|
||||||
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
|
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||||
)
|
)
|
||||||
|
|
||||||
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
||||||
@@ -556,6 +556,13 @@ class BoltzFundingSource(LNbitsSettings):
|
|||||||
boltz_client_cert: str | None = Field(default=None)
|
boltz_client_cert: 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 LightningSettings(LNbitsSettings):
|
class LightningSettings(LNbitsSettings):
|
||||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||||
|
|
||||||
@@ -580,6 +587,7 @@ class FundingSourcesSettings(
|
|||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
|
StrikeFundingSource,
|
||||||
):
|
):
|
||||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||||
# How long to wait for the payment to be confirmed before returning a pending status
|
# How long to wait for the payment to be confirmed before returning a pending status
|
||||||
@@ -659,6 +667,8 @@ class KeycloakAuthSettings(LNbitsSettings):
|
|||||||
keycloak_discovery_url: str = Field(default="")
|
keycloak_discovery_url: str = Field(default="")
|
||||||
keycloak_client_id: str = Field(default="")
|
keycloak_client_id: str = Field(default="")
|
||||||
keycloak_client_secret: str = Field(default="")
|
keycloak_client_secret: str = Field(default="")
|
||||||
|
keycloak_client_custom_org: str | None = Field(default=None)
|
||||||
|
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class AuditSettings(LNbitsSettings):
|
class AuditSettings(LNbitsSettings):
|
||||||
@@ -863,6 +873,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
"NWCWallet",
|
"NWCWallet",
|
||||||
|
"StrikeWallet",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+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
@@ -49,8 +49,11 @@ window.localisation.en = {
|
|||||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||||
access_wallet_on_mobile: 'Mobile Access',
|
access_wallet_on_mobile: 'Mobile Access',
|
||||||
wallet: 'Wallet: ',
|
wallet: 'Wallet: ',
|
||||||
|
wallet_name: 'Wallet name',
|
||||||
wallets: 'Wallets',
|
wallets: 'Wallets',
|
||||||
add_wallet: 'Add a new wallet',
|
add_wallet: 'Add wallet',
|
||||||
|
add_new_wallet: 'Add a new wallet',
|
||||||
|
pin_wallet: 'Pin wallet',
|
||||||
delete_wallet: 'Delete wallet',
|
delete_wallet: 'Delete wallet',
|
||||||
delete_wallet_desc:
|
delete_wallet_desc:
|
||||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||||
@@ -125,6 +128,7 @@ window.localisation.en = {
|
|||||||
no_extensions: "You don't have any extensions installed :(",
|
no_extensions: "You don't have any extensions installed :(",
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
search_extensions: 'Search extensions',
|
search_extensions: 'Search extensions',
|
||||||
|
search_wallets: 'Search wallets',
|
||||||
extension_sources: 'Extension Sources',
|
extension_sources: 'Extension Sources',
|
||||||
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
||||||
ext_sources_label:
|
ext_sources_label:
|
||||||
@@ -264,6 +268,7 @@ window.localisation.en = {
|
|||||||
notification_source_label:
|
notification_source_label:
|
||||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||||
more: 'more',
|
more: 'more',
|
||||||
|
more_count: '{count} more',
|
||||||
less: 'less',
|
less: 'less',
|
||||||
releases: 'Releases',
|
releases: 'Releases',
|
||||||
watchdog: 'Watchdog',
|
watchdog: 'Watchdog',
|
||||||
@@ -278,7 +283,7 @@ window.localisation.en = {
|
|||||||
callback_url_rules: 'Callback URL Rules',
|
callback_url_rules: 'Callback URL Rules',
|
||||||
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
||||||
callback_url_rule_hint:
|
callback_url_rule_hint:
|
||||||
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
|
'Callback URLs (like LNURL one) will be validated against these rules. At leat one rule must match. No rule means all URLs are allowed.',
|
||||||
wallet_limiter: 'Wallet Limiter',
|
wallet_limiter: 'Wallet Limiter',
|
||||||
wallet_config: 'Wallet Config',
|
wallet_config: 'Wallet Config',
|
||||||
wallet_charts: 'Wallet Charts',
|
wallet_charts: 'Wallet Charts',
|
||||||
@@ -303,6 +308,9 @@ window.localisation.en = {
|
|||||||
login_with_user_id: 'Login with user ID',
|
login_with_user_id: 'Login with user ID',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
create_new_wallet: 'Create New Wallet',
|
create_new_wallet: 'Create New Wallet',
|
||||||
|
delete_all_wallets: 'Delete All Wallets',
|
||||||
|
confirm_delete_all_wallets:
|
||||||
|
'Are you sure you want to delete ALL wallets for this user?',
|
||||||
login_to_account: 'Login to your account',
|
login_to_account: 'Login to your account',
|
||||||
create_account: 'Create account',
|
create_account: 'Create account',
|
||||||
account_settings: 'Account Settings',
|
account_settings: 'Account Settings',
|
||||||
@@ -311,7 +319,7 @@ window.localisation.en = {
|
|||||||
signin_with_nostr: 'Continue with Nostr',
|
signin_with_nostr: 'Continue with Nostr',
|
||||||
signin_with_google: 'Sign in with Google',
|
signin_with_google: 'Sign in with Google',
|
||||||
signin_with_github: 'Sign in with GitHub',
|
signin_with_github: 'Sign in with GitHub',
|
||||||
signin_with_keycloak: 'Sign in with Keycloak',
|
signin_with_custom_org: 'Sign in with {custom_org}',
|
||||||
username_or_email: 'Username or Email',
|
username_or_email: 'Username or Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
password_config: 'Password Config',
|
password_config: 'Password Config',
|
||||||
@@ -330,6 +338,7 @@ window.localisation.en = {
|
|||||||
username: 'Username',
|
username: 'Username',
|
||||||
pubkey: 'Public Key',
|
pubkey: 'Public Key',
|
||||||
user_id: 'User ID',
|
user_id: 'User ID',
|
||||||
|
id: 'ID',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
first_name: 'First Name',
|
first_name: 'First Name',
|
||||||
last_name: 'Last Name',
|
last_name: 'Last Name',
|
||||||
@@ -356,6 +365,7 @@ window.localisation.en = {
|
|||||||
gradient_background: 'Gradient Background',
|
gradient_background: 'Gradient Background',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
color_scheme: 'Color Scheme',
|
color_scheme: 'Color Scheme',
|
||||||
|
visible_wallet_count: 'Visible Wallet Count',
|
||||||
admin_settings: 'Admin Settings',
|
admin_settings: 'Admin Settings',
|
||||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||||
@@ -472,6 +482,8 @@ window.localisation.en = {
|
|||||||
auth_keycloak_ci_hint:
|
auth_keycloak_ci_hint:
|
||||||
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
||||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||||
|
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||||
|
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||||
currency_settings: 'Currency Settings',
|
currency_settings: 'Currency Settings',
|
||||||
allowed_currencies: 'Allowed Currencies',
|
allowed_currencies: 'Allowed Currencies',
|
||||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ window.LNbits = {
|
|||||||
email: data.email,
|
email: data.email,
|
||||||
extensions: data.extensions,
|
extensions: data.extensions,
|
||||||
wallets: data.wallets,
|
wallets: data.wallets,
|
||||||
super_user: data.super_user
|
super_user: data.super_user,
|
||||||
|
extra: data.extra ?? {}
|
||||||
}
|
}
|
||||||
const mapWallet = this.wallet
|
const mapWallet = this.wallet
|
||||||
obj.wallets = obj.wallets
|
obj.wallets = obj.wallets
|
||||||
@@ -205,6 +206,9 @@ window.LNbits = {
|
|||||||
return mapWallet(obj)
|
return mapWallet(obj)
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
if (a.extra.pinned !== b.extra.pinned) {
|
||||||
|
return a.extra.pinned ? -1 : 1
|
||||||
|
}
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
obj.walletOptions = obj.wallets.map(obj => {
|
obj.walletOptions = obj.wallets.map(obj => {
|
||||||
@@ -213,6 +217,10 @@ window.LNbits = {
|
|||||||
value: obj.id
|
value: obj.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
obj.hiddenWalletsCount = Math.max(
|
||||||
|
0,
|
||||||
|
data.wallets.length - data.extra.visible_wallet_count
|
||||||
|
)
|
||||||
return obj
|
return obj
|
||||||
},
|
},
|
||||||
wallet(data) {
|
wallet(data) {
|
||||||
@@ -252,13 +260,11 @@ window.LNbits = {
|
|||||||
fiat_currency: data.fiat_currency
|
fiat_currency: data.fiat_currency
|
||||||
}
|
}
|
||||||
|
|
||||||
obj.date = Quasar.date.formatDate(new Date(obj.time), window.dateFormat)
|
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||||
obj.dateFrom = moment.utc(obj.date).fromNow()
|
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
||||||
obj.expirydate = Quasar.date.formatDate(
|
|
||||||
new Date(obj.expiry),
|
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||||
window.dateFormat
|
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||||
)
|
|
||||||
obj.expirydateFrom = moment.utc(obj.expirydate).fromNow()
|
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra?.tag
|
obj.tag = obj.extra?.tag
|
||||||
@@ -510,6 +516,9 @@ window.windowMixin = {
|
|||||||
}
|
}
|
||||||
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
||||||
},
|
},
|
||||||
|
goToWallets() {
|
||||||
|
window.location = '/wallets'
|
||||||
|
},
|
||||||
submitAddWallet() {
|
submitAddWallet() {
|
||||||
if (
|
if (
|
||||||
this.showAddWalletDialog.name &&
|
this.showAddWalletDialog.name &&
|
||||||
|
|||||||
@@ -103,14 +103,7 @@ window.app.component('lnbits-extension-list', {
|
|||||||
window.app.component('lnbits-manage', {
|
window.app.component('lnbits-manage', {
|
||||||
mixins: [window.windowMixin],
|
mixins: [window.windowMixin],
|
||||||
template: '#lnbits-manage',
|
template: '#lnbits-manage',
|
||||||
props: [
|
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
|
||||||
'showAdmin',
|
|
||||||
'showNode',
|
|
||||||
'showExtensions',
|
|
||||||
'showUsers',
|
|
||||||
'showAudit',
|
|
||||||
'showPayments'
|
|
||||||
],
|
|
||||||
methods: {
|
methods: {
|
||||||
isActive(path) {
|
isActive(path) {
|
||||||
return window.location.pathname === path
|
return window.location.pathname === path
|
||||||
@@ -210,7 +203,7 @@ window.app.component('lnbits-qrcode', {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
custom: {
|
custom: {
|
||||||
margin: 1,
|
margin: 3,
|
||||||
width: 350,
|
width: 350,
|
||||||
size: 350,
|
size: 350,
|
||||||
logo: LNBITS_QR_LOGO
|
logo: LNBITS_QR_LOGO
|
||||||
@@ -566,7 +559,9 @@ window.app.component('username-password', {
|
|||||||
username: this.userName,
|
username: this.userName,
|
||||||
password: this.password_1,
|
password: this.password_1,
|
||||||
passwordRepeat: this.password_2,
|
passwordRepeat: this.password_2,
|
||||||
reset_key: this.resetKey
|
reset_key: this.resetKey,
|
||||||
|
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
|
||||||
|
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -197,6 +197,14 @@ window.app.component('lnbits-funding-sources', {
|
|||||||
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
||||||
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'StrikeWallet',
|
||||||
|
'Strike (alpha)',
|
||||||
|
{
|
||||||
|
strike_api_endpoint: 'API Endpoint',
|
||||||
|
strike_api_key: 'API Key'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ const routes = [
|
|||||||
scripts: ['/static/js/account.js']
|
scripts: ['/static/js/account.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/wallets',
|
||||||
|
name: 'Wallets',
|
||||||
|
component: DynamicComponent,
|
||||||
|
props: {
|
||||||
|
fetchUrl: '/wallets',
|
||||||
|
scripts: ['/static/js/wallets.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/node',
|
path: '/node',
|
||||||
name: 'Node',
|
name: 'Node',
|
||||||
|
|||||||
@@ -312,6 +312,23 @@ window.UsersPageLogic = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
deleteAllUserWallets(userId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(this.$t('confirm_delete_all_wallets'))
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('DELETE', `/users/api/v1/user/${userId}/wallets`)
|
||||||
|
.then(response => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: response.data.message,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.fetchWallets(userId)
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
copyWalletLink(walletId) {
|
copyWalletLink(walletId) {
|
||||||
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
||||||
this.copyText(url)
|
this.copyText(url)
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
message: 'Wallet and user updated.',
|
message: 'Wallet updated.',
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
window.WalletsPageLogic = {
|
||||||
|
mixins: [window.windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
tab: 'wallets',
|
||||||
|
wallets: [],
|
||||||
|
showAddWalletDialog: {show: false},
|
||||||
|
walletsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'currency',
|
||||||
|
align: 'center',
|
||||||
|
label: 'Currency',
|
||||||
|
field: 'currency',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updated_at',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Last Updated',
|
||||||
|
field: 'updated_at',
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
sortBy: 'updated_at',
|
||||||
|
rowsPerPage: 12,
|
||||||
|
page: 1,
|
||||||
|
descending: true,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
search: '',
|
||||||
|
hideEmpty: true,
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'walletsTable.search': {
|
||||||
|
handler() {
|
||||||
|
const props = {}
|
||||||
|
if (this.walletsTable.search) {
|
||||||
|
props['search'] = this.walletsTable.search
|
||||||
|
}
|
||||||
|
this.getUserWallets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getUserWallets(props) {
|
||||||
|
try {
|
||||||
|
this.walletsTable.loading = true
|
||||||
|
const params = LNbits.utils.prepareFilterQuery(this.walletsTable, props)
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/wallet/paginated?${params}`,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
this.wallets = data.data
|
||||||
|
this.walletsTable.pagination.rowsNumber = data.total
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.walletsTable.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToWallet(walletId) {
|
||||||
|
window.location = `/wallet?wal=${walletId}`
|
||||||
|
},
|
||||||
|
formattedFiatAmount(amount, currency) {
|
||||||
|
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
|
||||||
|
},
|
||||||
|
formattedSatAmount(amount) {
|
||||||
|
return LNbits.utils.formatMsat(amount) + ' sat'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
await this.getUserWallets()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,12 +179,12 @@
|
|||||||
>
|
>
|
||||||
<q-scroll-area style="height: 100%">
|
<q-scroll-area style="height: 100%">
|
||||||
<q-item>
|
<q-item>
|
||||||
<q-item-section>
|
<q-item-section class="cursor-pointer" @click="goToWallets()">
|
||||||
<q-item-label
|
<q-item-label
|
||||||
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
|
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
|
||||||
class="q-item__label q-item__label--header q-pa-none"
|
class="q-item__label q-item__label--header q-pa-none"
|
||||||
header
|
header
|
||||||
v-text="$t('wallets')"
|
v-text="$t('wallets') + ' (' + g.user.wallets.length + ')'"
|
||||||
></q-item-label>
|
></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
@@ -211,7 +211,6 @@
|
|||||||
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
|
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
|
||||||
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
|
||||||
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||||
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
||||||
></lnbits-manage>
|
></lnbits-manage>
|
||||||
@@ -248,7 +247,7 @@
|
|||||||
@click="showAddWalletDialog.show = true"
|
@click="showAddWalletDialog.show = true"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('add_wallet')"></span
|
><span v-text="$t('add_new_wallet')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-dialog
|
<q-dialog
|
||||||
@@ -258,7 +257,9 @@
|
|||||||
>
|
>
|
||||||
<q-card style="min-width: 350px">
|
<q-card style="min-width: 350px">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">Wallet name</div>
|
<div class="text-h6">
|
||||||
|
<span v-text="$t('wallet_name')"></span>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section class="q-pt-none">
|
<q-card-section class="q-pt-none">
|
||||||
@@ -271,10 +272,14 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="right" class="text-primary">
|
<q-card-actions align="right" class="text-primary">
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
label="Add wallet"
|
:label="$t('cancel')"
|
||||||
|
v-close-popup
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
:label="$t('add_wallet')"
|
||||||
v-close-popup
|
v-close-popup
|
||||||
@click="submitAddWallet()"
|
@click="submitAddWallet()"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
@@ -285,7 +290,7 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card
|
<q-card
|
||||||
v-for="wallet in g.user.wallets"
|
v-for="wallet in g.user.wallets.slice(0, g.user.extra.visible_wallet_count || 10)"
|
||||||
:key="wallet.id"
|
:key="wallet.id"
|
||||||
clickable
|
clickable
|
||||||
@click="selectWallet(wallet)"
|
@click="selectWallet(wallet)"
|
||||||
@@ -331,6 +336,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
<q-card
|
||||||
|
v-if="g.user.hiddenWalletsCount"
|
||||||
|
class="wallet-list-card"
|
||||||
|
>
|
||||||
|
<q-card-section
|
||||||
|
class="flex flex-center column full-height text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="more_horiz"
|
||||||
|
@click="goToWallets()"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
><span
|
||||||
|
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
|
||||||
|
></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</q-scroll-area>
|
</q-scroll-area>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
class="lnbits-drawer__q-list"
|
class="lnbits-drawer__q-list"
|
||||||
>
|
>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="walletRec in g.user.wallets"
|
v-for="walletRec in g.user.wallets.slice(
|
||||||
|
0,
|
||||||
|
g.user.extra.visible_wallet_count || 10
|
||||||
|
)"
|
||||||
:key="walletRec.id"
|
:key="walletRec.id"
|
||||||
clickable
|
clickable
|
||||||
:active="g.wallet && g.wallet.id === walletRec.id"
|
:active="g.wallet && g.wallet.id === walletRec.id"
|
||||||
@@ -43,6 +46,22 @@
|
|||||||
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
|
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-item
|
||||||
|
v-if="g.user.hiddenWalletsCount > 0"
|
||||||
|
clickable
|
||||||
|
@click="goToWallets()"
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label
|
||||||
|
lines="1"
|
||||||
|
class="text-caption"
|
||||||
|
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
|
||||||
|
></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
<q-item clickable @click="showForm = !showForm">
|
<q-item clickable @click="showForm = !showForm">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon
|
<q-icon
|
||||||
@@ -55,7 +74,7 @@
|
|||||||
<q-item-label
|
<q-item-label
|
||||||
lines="1"
|
lines="1"
|
||||||
class="text-caption"
|
class="text-caption"
|
||||||
v-text="$t('add_wallet')"
|
v-text="$t('add_new_wallet')"
|
||||||
></q-item-label>
|
></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
@@ -178,19 +197,19 @@
|
|||||||
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
|
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item v-if="showPayments" to="/payments">
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon
|
|
||||||
name="query_stats"
|
|
||||||
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
|
||||||
size="md"
|
|
||||||
></q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</div>
|
</div>
|
||||||
|
<q-item to="/payments">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon
|
||||||
|
name="query_stats"
|
||||||
|
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
||||||
|
size="md"
|
||||||
|
></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
<q-item v-if="showExtensions" to="/extensions">
|
<q-item v-if="showExtensions" to="/extensions">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon
|
<q-icon
|
||||||
@@ -987,7 +1006,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row">
|
<div class="row q-gutter-x-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="
|
v-if="
|
||||||
props.row.isIn && props.row.isPending && props.row.bolt11
|
props.row.isIn && props.row.isPending && props.row.bolt11
|
||||||
@@ -1185,7 +1204,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
:disable="walletName == ''"
|
:disable="walletName == ''"
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="$t('add_wallet')"
|
:label="$t('add_new_wallet')"
|
||||||
class="full-width q-mb-sm"
|
class="full-width q-mb-sm"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -1453,10 +1472,22 @@
|
|||||||
>
|
>
|
||||||
<q-avatar size="32px" class="q-mr-md">
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
<q-img
|
<q-img
|
||||||
:src="`{{ static_url_for('static', 'images/keycloak-logo.png') }}`"
|
:src="
|
||||||
|
keycloakIcon
|
||||||
|
? keycloakIcon
|
||||||
|
: `{{ static_url_for('static', 'images/keycloak-logo.png') }}`
|
||||||
|
"
|
||||||
></q-img>
|
></q-img>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div><span v-text="$t('signin_with_keycloak')"></span></div>
|
<div>
|
||||||
|
<span
|
||||||
|
v-text="
|
||||||
|
$t('signin_with_custom_org', {
|
||||||
|
custom_org: keycloakOrg
|
||||||
|
})
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
+15
-6
@@ -56,10 +56,11 @@ class AESCipher:
|
|||||||
return data + (chr(length) * length).encode()
|
return data + (chr(length) * length).encode()
|
||||||
|
|
||||||
def unpad(self, data: bytes) -> bytes:
|
def unpad(self, data: bytes) -> bytes:
|
||||||
_last = data[-1]
|
padding = data[-1]
|
||||||
if isinstance(_last, int):
|
# Ensure padding is within valid range else there is no padding
|
||||||
return data[:-_last]
|
if padding <= 0 or padding >= self.block_size:
|
||||||
return data[: -ord(_last)]
|
return data
|
||||||
|
return data[:-padding]
|
||||||
|
|
||||||
def derive_iv_and_key(
|
def derive_iv_and_key(
|
||||||
self, salt: bytes, output_len: int = 32 + 16
|
self, salt: bytes, output_len: int = 32 + 16
|
||||||
@@ -93,9 +94,17 @@ class AESCipher:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
decrypted_bytes = aes.decrypt(encrypted_bytes)
|
decrypted_bytes = aes.decrypt(encrypted_bytes)
|
||||||
return self.unpad(decrypted_bytes).decode()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError("Decryption error") from exc
|
raise ValueError("Could not decrypt payload") from exc
|
||||||
|
|
||||||
|
unpadded = self.unpad(decrypted_bytes)
|
||||||
|
if len(unpadded) == 0:
|
||||||
|
raise ValueError("Unpadding resulted in empty data.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return unpadded.decode()
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("Decryption resulted in invalid UTF-8 data.") from exc
|
||||||
|
|
||||||
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
|
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .nwc import NWCWallet
|
|||||||
from .opennode import OpenNodeWallet
|
from .opennode import OpenNodeWallet
|
||||||
from .phoenixd import PhoenixdWallet
|
from .phoenixd import PhoenixdWallet
|
||||||
from .spark import SparkWallet
|
from .spark import SparkWallet
|
||||||
|
from .strike import StrikeWallet
|
||||||
from .void import VoidWallet
|
from .void import VoidWallet
|
||||||
from .zbd import ZBDWallet
|
from .zbd import ZBDWallet
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ __all__ = [
|
|||||||
"OpenNodeWallet",
|
"OpenNodeWallet",
|
||||||
"PhoenixdWallet",
|
"PhoenixdWallet",
|
||||||
"SparkWallet",
|
"SparkWallet",
|
||||||
|
"StrikeWallet",
|
||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
InvoiceResponse,
|
||||||
|
PaymentFailedStatus,
|
||||||
|
PaymentPendingStatus,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
PaymentSuccessStatus,
|
||||||
|
StatusResponse,
|
||||||
|
Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBucket:
|
||||||
|
"""
|
||||||
|
Token bucket rate limiter for Strike API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rate: int, period_seconds: int):
|
||||||
|
"""
|
||||||
|
Initialize a token bucket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rate: Max requests allowed in the period
|
||||||
|
period_seconds: Time period in seconds.
|
||||||
|
"""
|
||||||
|
self.rate = rate
|
||||||
|
self.period = period_seconds
|
||||||
|
self.tokens = rate
|
||||||
|
self.last_refill = time.monotonic()
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def consume(self) -> None:
|
||||||
|
"""Wait until a token is available and consume it."""
|
||||||
|
async with self.lock:
|
||||||
|
# Refill tokens based on elapsed time
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self.last_refill
|
||||||
|
|
||||||
|
if elapsed > 0:
|
||||||
|
new_tokens = int((elapsed / self.period) * self.rate)
|
||||||
|
self.tokens = min(self.rate, self.tokens + new_tokens)
|
||||||
|
self.last_refill = now
|
||||||
|
|
||||||
|
# If no tokens available, calculate wait time and wait for a token
|
||||||
|
if self.tokens < 1:
|
||||||
|
# Calculate time needed for one token
|
||||||
|
wait_time = (self.period / self.rate) * (1 - self.tokens)
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
|
# After waiting, update time and add one token
|
||||||
|
self.last_refill = time.monotonic()
|
||||||
|
self.tokens = 1
|
||||||
|
|
||||||
|
# Consume a token (will be 0 or more after consumption)
|
||||||
|
self.tokens -= 1
|
||||||
|
|
||||||
|
|
||||||
|
class StrikeWallet(Wallet):
|
||||||
|
"""
|
||||||
|
https://developer.strike.me/api
|
||||||
|
A minimal LNbits wallet backend for Strike.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# construction / teardown #
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not settings.strike_api_endpoint:
|
||||||
|
raise ValueError("Missing strike_api_endpoint")
|
||||||
|
if not settings.strike_api_key:
|
||||||
|
raise ValueError("Missing strike_api_key")
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
# throttle
|
||||||
|
self._sem = asyncio.Semaphore(value=20)
|
||||||
|
|
||||||
|
# Rate limiters for different API endpoints
|
||||||
|
# Invoice/payment operations: 250 requests / 1 minute
|
||||||
|
self._invoice_limiter = TokenBucket(250, 60)
|
||||||
|
self._payment_limiter = TokenBucket(250, 60)
|
||||||
|
# All other operations: 1,000 requests / 10 minutes
|
||||||
|
self._general_limiter = TokenBucket(1000, 600)
|
||||||
|
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=self.normalize_endpoint(settings.strike_api_endpoint),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.strike_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": settings.user_agent,
|
||||||
|
},
|
||||||
|
timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
|
||||||
|
transport=httpx.AsyncHTTPTransport(
|
||||||
|
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
|
||||||
|
retries=0, # we handle retries ourselves
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# runtime state
|
||||||
|
self.pending_invoices: list[str] = [] # Keep it as a list
|
||||||
|
self.pending_payments: Dict[str, str] = {}
|
||||||
|
self.failed_payments: Dict[str, str] = {}
|
||||||
|
|
||||||
|
# balance cache
|
||||||
|
self._cached_balance: Optional[int] = None
|
||||||
|
self._cached_balance_ts: float = 0.0
|
||||||
|
self._cache_ttl = 30 # seconds
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
try:
|
||||||
|
await self.client.aclose()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Error closing Strike client")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# low-level request helpers #
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
|
||||||
|
"""Make a Strike HTTP call with:
|
||||||
|
One Strike HTTP call with
|
||||||
|
• rate limiting based on endpoint type
|
||||||
|
• concurrency throttle
|
||||||
|
• exponential back-off + jitter
|
||||||
|
• explicit retry on 429/5xx
|
||||||
|
• latency logging
|
||||||
|
"""
|
||||||
|
# Apply the appropriate rate limiter based on the endpoint path.
|
||||||
|
if path.startswith("/invoices") or path.startswith("/receive-requests"):
|
||||||
|
await self._invoice_limiter.consume()
|
||||||
|
elif path.startswith("/payment-quotes"):
|
||||||
|
await self._payment_limiter.consume()
|
||||||
|
else:
|
||||||
|
await self._general_limiter.consume()
|
||||||
|
|
||||||
|
async with self._sem:
|
||||||
|
return await self.client.request(method, path, **kw)
|
||||||
|
|
||||||
|
# Typed wrappers - so call-sites stay tidy.
|
||||||
|
async def _get(self, path: str, **kw) -> httpx.Response: # GET request.
|
||||||
|
return await self._req("GET", path, **kw)
|
||||||
|
|
||||||
|
async def _post(self, path: str, **kw) -> httpx.Response:
|
||||||
|
return await self._req("POST", path, **kw)
|
||||||
|
|
||||||
|
async def _patch(self, path: str, **kw) -> httpx.Response:
|
||||||
|
return await self._req("PATCH", path, **kw)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# LNbits wallet API implementation #
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
async def status(self) -> StatusResponse:
|
||||||
|
"""
|
||||||
|
Return wallet balance (millisatoshis) with a 30-second cache.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self._cached_balance is not None
|
||||||
|
and now - self._cached_balance_ts < self._cache_ttl
|
||||||
|
):
|
||||||
|
return StatusResponse(None, self._cached_balance)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self._get("/balances")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
balances = data.get("data", []) if isinstance(data, dict) else data
|
||||||
|
btc = next((b for b in balances if b.get("currency") == "BTC"), None)
|
||||||
|
if btc and "available" in btc:
|
||||||
|
available_btc = Decimal(btc["available"]) # Get available BTC amount.
|
||||||
|
msats = int(
|
||||||
|
available_btc * Decimal(1e11)
|
||||||
|
) # Convert BTC to millisatoshis.
|
||||||
|
self._cached_balance = msats
|
||||||
|
self._cached_balance_ts = now
|
||||||
|
return StatusResponse(None, msats)
|
||||||
|
|
||||||
|
return StatusResponse(None, 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return StatusResponse("Connection error", 0)
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
memo: Optional[str] = None,
|
||||||
|
description_hash: Optional[bytes] = None,
|
||||||
|
unhashed_description: bytes | None = None, # Add this parameter
|
||||||
|
**kwargs,
|
||||||
|
) -> InvoiceResponse:
|
||||||
|
try:
|
||||||
|
btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
|
||||||
|
Decimal("0.00000001")
|
||||||
|
) # Convert amount from millisatoshis to BTC.
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"bolt11": {
|
||||||
|
"amount": {
|
||||||
|
"currency": "BTC",
|
||||||
|
"amount": str(btc_amt),
|
||||||
|
},
|
||||||
|
"description": memo or "",
|
||||||
|
},
|
||||||
|
"targetCurrency": "BTC",
|
||||||
|
}
|
||||||
|
if description_hash:
|
||||||
|
payload["bolt11"]["descriptionHash"] = description_hash.hex()
|
||||||
|
|
||||||
|
r = await self._post(
|
||||||
|
"/receive-requests",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
invoice_id = resp.get("receiveRequestId")
|
||||||
|
bolt11 = resp.get("bolt11", {}).get("invoice")
|
||||||
|
if not invoice_id or not bolt11:
|
||||||
|
return InvoiceResponse(
|
||||||
|
ok=False, error_message="Invalid invoice response"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.pending_invoices.append(invoice_id)
|
||||||
|
return InvoiceResponse(
|
||||||
|
ok=True, checking_id=invoice_id, payment_request=bolt11
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(e)
|
||||||
|
msg = e.response.json().get("message", e.response.text)
|
||||||
|
return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return InvoiceResponse(ok=False, error_message="Connection error")
|
||||||
|
|
||||||
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
|
try:
|
||||||
|
# 1) Create a payment quote.
|
||||||
|
q = await self._post(
|
||||||
|
"/payment-quotes/lightning",
|
||||||
|
json={"lnInvoice": bolt11},
|
||||||
|
)
|
||||||
|
q.raise_for_status()
|
||||||
|
quote_id = q.json().get("paymentQuoteId")
|
||||||
|
if not quote_id:
|
||||||
|
return PaymentResponse(
|
||||||
|
ok=False, error_message="Strike: missing payment quote Id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Execute the payment quote.
|
||||||
|
e = await self._patch(f"/payment-quotes/{quote_id}/execute")
|
||||||
|
e.raise_for_status()
|
||||||
|
|
||||||
|
data = e.json() if e.content else {}
|
||||||
|
payment_id = data.get("paymentId")
|
||||||
|
state = data.get("state", "").upper()
|
||||||
|
|
||||||
|
# Network fee → msat.
|
||||||
|
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}
|
||||||
|
fee_btc = Decimal(fee_obj.get("amount", "0"))
|
||||||
|
fee_msat = int(fee_btc * Decimal(1e11)) # millisatoshis.
|
||||||
|
|
||||||
|
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||||
|
preimage = data.get("preimage") or data.get("preImage")
|
||||||
|
return PaymentResponse(
|
||||||
|
ok=True,
|
||||||
|
checking_id=payment_id,
|
||||||
|
fee_msat=fee_msat,
|
||||||
|
preimage=preimage,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_states = {
|
||||||
|
"CANCELED",
|
||||||
|
"FAILED",
|
||||||
|
"TIMED_OUT",
|
||||||
|
}
|
||||||
|
if state in failed_states:
|
||||||
|
return PaymentResponse(
|
||||||
|
ok=False, checking_id=payment_id, error_message=f"State: {state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store mapping for later polling.
|
||||||
|
if payment_id:
|
||||||
|
# todo: this will be lost on server restart
|
||||||
|
self.pending_payments[payment_id] = quote_id
|
||||||
|
|
||||||
|
# Treat all other states as pending (including unknown states).
|
||||||
|
return PaymentResponse(ok=None, checking_id=payment_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
# Keep pending. Not sure if the payment went trough or not.
|
||||||
|
return PaymentResponse(ok=None, error_message="Connection error")
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
try:
|
||||||
|
r = await self._get(f"/receive-requests/{checking_id}/receives")
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("items", [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
# Still pending.
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.get("state") == "COMPLETED":
|
||||||
|
preimage = None
|
||||||
|
lightning_data = item.get("lightning")
|
||||||
|
if lightning_data:
|
||||||
|
preimage = lightning_data.get(
|
||||||
|
"preimage"
|
||||||
|
) or lightning_data.get("preImage")
|
||||||
|
|
||||||
|
return PaymentSuccessStatus(fee_msat=0, preimage=preimage)
|
||||||
|
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
if r.status_code == 404:
|
||||||
|
logger.warning(f"Payment '{checking_id}' not found. Marking as failed.")
|
||||||
|
return PaymentFailedStatus(False)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"HTTPStatusError in get_invoice_status for checking_id {checking_id} "
|
||||||
|
f"on URL {e.request.url}: {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
# Default to Pending to allow retries by paid_invoices_stream.
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
quote_id = self.pending_payments.get(checking_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt 1: Use quote_id if available (from in-memory store)
|
||||||
|
if quote_id:
|
||||||
|
status = await self._get_payment_status_by_quote_id(
|
||||||
|
checking_id, quote_id
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
return status
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
logger.debug(f"Error while fetching payment by quote id {checking_id}.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt 2: Fallback - Use paymentId (checking_id) directly.
|
||||||
|
return await self._get_payment_status_by_checking_id(checking_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
logger.debug(f"Error while fetching payment {checking_id}.")
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Poll Strike for invoice settlement while respecting the documented API limits.
|
||||||
|
|
||||||
|
Uses dynamic adjustment of polling frequency based on activity.
|
||||||
|
"""
|
||||||
|
min_poll, max_poll = 1, 15
|
||||||
|
# 1,000 requests / 10 minutes = ~100 requests/minute.
|
||||||
|
rate_limit = 100
|
||||||
|
sleep_s = min_poll
|
||||||
|
# Main loop for polling invoices.
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
while self._running and settings.lnbits_running:
|
||||||
|
loop_start = time.time()
|
||||||
|
had_activity = False
|
||||||
|
|
||||||
|
req_budget = max(
|
||||||
|
1, rate_limit * sleep_s // 60
|
||||||
|
) # Calculate request budget based on sleep time.
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for inv in list(self.pending_invoices):
|
||||||
|
if processed >= req_budget: # If request budget is exhausted.
|
||||||
|
break
|
||||||
|
status = await self.get_invoice_status(inv)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
if status.success or status.failed:
|
||||||
|
self.pending_invoices.remove(inv)
|
||||||
|
if status.success:
|
||||||
|
had_activity = True
|
||||||
|
yield inv
|
||||||
|
|
||||||
|
# Dynamic adjustment of polling frequency based on activity.
|
||||||
|
sleep_s = (
|
||||||
|
max(min_poll, sleep_s // 2)
|
||||||
|
if had_activity
|
||||||
|
else min(max_poll, sleep_s * 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sleep to respect rate limits.
|
||||||
|
elapsed = time.time() - loop_start
|
||||||
|
# Ensure we respect the rate limit, even with dynamic adjustment.
|
||||||
|
min_sleep_for_rate = processed * 60 / rate_limit - elapsed
|
||||||
|
await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# misc Strike helpers #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def get_invoices(
|
||||||
|
self,
|
||||||
|
filters: Optional[str] = None,
|
||||||
|
orderby: Optional[str] = None,
|
||||||
|
skip: Optional[int] = None,
|
||||||
|
top: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
params: Dict[str, Any] = {}
|
||||||
|
if filters:
|
||||||
|
params["$filter"] = filters
|
||||||
|
if orderby:
|
||||||
|
params["$orderby"] = orderby
|
||||||
|
if skip is not None:
|
||||||
|
params["$skip"] = skip
|
||||||
|
if top is not None:
|
||||||
|
params["$top"] = top
|
||||||
|
r = await self._get(
|
||||||
|
"/invoices", params=params
|
||||||
|
) # Get invoices from Strike API.
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Error in get_invoices()")
|
||||||
|
return {"error": "unable to fetch invoices"}
|
||||||
|
|
||||||
|
async def _get_payment_status_by_quote_id(
|
||||||
|
self, checking_id: str, quote_id: str
|
||||||
|
) -> Optional[PaymentStatus]:
|
||||||
|
resp = await self._get(f"/payment-quotes/{quote_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
state = data.get("state", "").upper()
|
||||||
|
preimage = data.get("preimage") or data.get("preImage")
|
||||||
|
|
||||||
|
fee_msat = 0
|
||||||
|
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee")
|
||||||
|
if fee_obj and fee_obj.get("amount") and fee_obj.get("currency"):
|
||||||
|
amount_str = fee_obj.get("amount")
|
||||||
|
currency_str = fee_obj.get("currency").upper()
|
||||||
|
try:
|
||||||
|
if currency_str == "BTC":
|
||||||
|
fee_btc_decimal = Decimal(amount_str)
|
||||||
|
fee_msat = int(fee_btc_decimal * Decimal(1e11))
|
||||||
|
elif currency_str == "SAT":
|
||||||
|
fee_sat_decimal = Decimal(amount_str)
|
||||||
|
fee_msat = int(fee_sat_decimal * 1000)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
logger.warning(
|
||||||
|
f"Fee parse error. Quote: '{quote_id}'. "
|
||||||
|
f"Payment '{checking_id}'."
|
||||||
|
)
|
||||||
|
fee_msat = 0
|
||||||
|
|
||||||
|
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||||
|
if state == "FAILED":
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_payment_status_by_checking_id(
|
||||||
|
self, checking_id: str
|
||||||
|
) -> PaymentStatus:
|
||||||
|
r_payment = await self._get(f"/payments/{checking_id}")
|
||||||
|
|
||||||
|
if r_payment.status_code == 200:
|
||||||
|
data = r_payment.json()
|
||||||
|
state = data.get("state", "").upper()
|
||||||
|
preimage = None
|
||||||
|
fee_msat = 0
|
||||||
|
|
||||||
|
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||||
|
if state == "FAILED":
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
if r_payment.status_code == 400:
|
||||||
|
try:
|
||||||
|
error_data = r_payment.json()
|
||||||
|
# Check for Strike's specific validation
|
||||||
|
# error structure for paymentId format
|
||||||
|
if error_data.get("data", {}).get("code") == "INVALID_DATA":
|
||||||
|
validation_errors = error_data.get("data", {}).get(
|
||||||
|
"validationErrors", {}
|
||||||
|
)
|
||||||
|
if "paymentId" in validation_errors:
|
||||||
|
for err_detail in validation_errors["paymentId"]:
|
||||||
|
is_invalid = err_detail.get(
|
||||||
|
"code"
|
||||||
|
) == "INVALID_DATA" and "is not valid." in err_detail.get(
|
||||||
|
"message", ""
|
||||||
|
)
|
||||||
|
if not is_invalid:
|
||||||
|
continue
|
||||||
|
logger.error(
|
||||||
|
f"Payment '{checking_id}' not a valid Strike payment. "
|
||||||
|
f"Marked as failed. Response: {r_payment.text}"
|
||||||
|
)
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
if r_payment.status_code == 404:
|
||||||
|
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
|
||||||
|
self.pending_payments.pop(checking_id, None)
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Error fetching payment {checking_id} directly: "
|
||||||
|
f"{r_payment.status_code} - {r_payment.text}"
|
||||||
|
)
|
||||||
|
return PaymentPendingStatus()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "1.1.0"
|
version = "1.2.0-rc1"
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
+2
-2
@@ -130,7 +130,7 @@ async def from_wallet(from_user):
|
|||||||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
|
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
|
||||||
await update_wallet_balance(
|
await update_wallet_balance(
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
amount=999999999,
|
amount=9999999,
|
||||||
)
|
)
|
||||||
yield wallet
|
yield wallet
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ async def to_wallet(to_user):
|
|||||||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
|
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
|
||||||
await update_wallet_balance(
|
await update_wallet_balance(
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
amount=999999999,
|
amount=9999999,
|
||||||
)
|
)
|
||||||
yield wallet
|
yield wallet
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ async def test_set_channel_fees(node_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
funding_source.__class__.__name__ == "LndRestWallet",
|
||||||
|
reason="Lndrest is slow / async with channel commands",
|
||||||
|
)
|
||||||
async def test_channel_management(node_client):
|
async def test_channel_management(node_client):
|
||||||
async def get_channels():
|
async def get_channels():
|
||||||
# lndrest is slow / async with channel commands
|
# lndrest is slow / async with channel commands
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from lnbits.helpers import check_callback_url
|
||||||
|
from lnbits.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
def test_check_callback_url_not_allowed(settings: Settings):
|
||||||
|
settings.lnbits_callback_url_rules = [
|
||||||
|
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="Callback not allowed. URL: xx. Netloc: ."):
|
||||||
|
check_callback_url("xx")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Callback not allowed. URL: http://localhost:3000/callback. "
|
||||||
|
"Netloc: localhost:3000. Please check your admin settings.",
|
||||||
|
):
|
||||||
|
check_callback_url("http://localhost:3000/callback")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Callback not allowed. URL: https://localhost:3000/callback. "
|
||||||
|
"Netloc: localhost:3000. Please check your admin settings.",
|
||||||
|
):
|
||||||
|
check_callback_url("https://localhost:3000/callback")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Callback not allowed. URL: http://192.168.2.2:3000/callback. "
|
||||||
|
"Netloc: 192.168.2.2:3000. Please check your admin settings.",
|
||||||
|
):
|
||||||
|
check_callback_url("http://192.168.2.2:3000/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
def test_check_callback_url_no_rules(settings: Settings):
|
||||||
|
settings.lnbits_callback_url_rules = [
|
||||||
|
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||||
|
]
|
||||||
|
settings.lnbits_callback_url_rules.append(".*")
|
||||||
|
check_callback_url("xyz")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
def test_check_callback_url_allow_all(settings: Settings):
|
||||||
|
settings.lnbits_callback_url_rules = []
|
||||||
|
check_callback_url("xyz")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
def test_check_callback_url_allowed(settings: Settings):
|
||||||
|
settings.lnbits_callback_url_rules = [
|
||||||
|
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||||
|
]
|
||||||
|
check_callback_url("http://google.com/callback")
|
||||||
|
check_callback_url("http://google.com:80/callback")
|
||||||
|
check_callback_url("http://google.com:8080/callback")
|
||||||
|
check_callback_url("https://google.com/callback")
|
||||||
|
check_callback_url("https://google.com:443/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
def test_check_callback_url_multiple_rules(settings: Settings):
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Callback not allowed. URL: http://localhost:3000/callback. "
|
||||||
|
"Netloc: localhost:3000. Please check your admin settings.",
|
||||||
|
):
|
||||||
|
check_callback_url("http://localhost:3000/callback")
|
||||||
|
|
||||||
|
settings.lnbits_callback_url_rules.append("http://localhost:3000")
|
||||||
|
check_callback_url("http://localhost:3000/callback") # should not raise
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Callback not allowed. URL: https://localhost:3000/callback. "
|
||||||
|
"Netloc: localhost:3000. Please check your admin settings.",
|
||||||
|
):
|
||||||
|
check_callback_url("https://localhost:3000/callback")
|
||||||
|
|
||||||
|
settings.lnbits_callback_url_rules.append("https://localhost:3000")
|
||||||
|
check_callback_url("https://localhost:3000/callback") # should not raise
|
||||||
Reference in New Issue
Block a user