Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c38cbcae8 | ||
|
|
385fb4f9bc | ||
|
|
a949242f7d | ||
|
|
8db76b8864 | ||
|
|
9e3ab0ef26 | ||
|
|
04c9b67997 | ||
|
|
116f982aab | ||
|
|
63cc89e2b6 | ||
|
|
183e6e5661 | ||
|
|
6b3fd80e46 | ||
|
|
5999a773be | ||
|
|
7e0fadad3b | ||
|
|
b2d6243697 | ||
|
|
6b5a77fb3b | ||
|
|
cc8fb68b02 | ||
|
|
658da6b28e | ||
|
|
7d734ecb74 | ||
|
|
9177dd195b | ||
|
|
15faab4f38 | ||
|
|
2cce687865 | ||
|
|
313bd3f647 | ||
|
|
13a93836d9 | ||
|
|
4f76d0483e | ||
|
|
719d86aa9c | ||
|
|
bbad4a91ae | ||
|
|
fcebb7e28c | ||
|
|
ce5aa4c8a7 | ||
|
|
75bae67446 | ||
|
|
8c184356ef | ||
|
|
efc0547271 | ||
|
|
7a393b11fd | ||
|
|
fae3eca3c7 | ||
|
|
3398070dd5 | ||
|
|
7d98c6b080 | ||
|
|
5f4e889d3a |
@@ -24,6 +24,10 @@ LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
# Hard limit for total created users. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_USERS=0
|
||||
# Hard limit for total installed extensions. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_EXTENSIONS=0
|
||||
|
||||
# === Admin Settings ===
|
||||
|
||||
@@ -94,7 +98,7 @@ AUTH_SECRET_KEY=""
|
||||
######################################
|
||||
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
@@ -271,6 +275,50 @@ KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# OIDC OAuth Config
|
||||
# Generic OIDC provider configuration
|
||||
# Make sure that the redirect URI in your OIDC provider is set to: https://{domain}/api/v1/auth/oidc/token
|
||||
# Required scopes: openid, email, profile
|
||||
# The discovery URL must be accessible from your LNbits server
|
||||
# Always use HTTPS in production environments
|
||||
# The CUSTOM_ORG and CUSTOM_ICON settings allow you to customize the login button
|
||||
# For example: "Login via Zitadel" with the Zitadel logo
|
||||
OIDC_DISCOVERY_URL=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_CLIENT_CUSTOM_ORG=""
|
||||
OIDC_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# Example OIDC configurations for various providers:
|
||||
#
|
||||
# ZITADEL:
|
||||
# OIDC_DISCOVERY_URL=https://login.yourdomain.de/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-zitadel-client-id@project-id
|
||||
# OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Zitadel
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/zitadel.png
|
||||
#
|
||||
# AUTHENTIK:
|
||||
# OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/lnbits/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authentik-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authentik
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authentik.png
|
||||
#
|
||||
# AUTHELIA:
|
||||
# OIDC_DISCOVERY_URL=https://auth.yourdomain.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authelia-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authelia-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authelia
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authelia.png
|
||||
#
|
||||
# OKTA:
|
||||
# OIDC_DISCOVERY_URL=https://your-domain.okta.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-okta-client-id
|
||||
# OIDC_CLIENT_SECRET=your-okta-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Okta
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/okta.png
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
|
||||
@@ -69,7 +69,10 @@ jobs:
|
||||
--onefile \
|
||||
--name lnbits \
|
||||
--hidden-import=embit \
|
||||
--hidden-import=bitstring.bitstore_bitarray \
|
||||
--collect-all embit \
|
||||
--collect-all bitstring \
|
||||
--collect-all bitarray \
|
||||
--collect-all lnbits \
|
||||
--collect-all sqlalchemy \
|
||||
--collect-all breez_sdk \
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: bundle
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- run: make bundle
|
||||
- name: Commit and push bundle changes
|
||||
run: |
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
@@ -1,14 +1,9 @@
|
||||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
@@ -16,7 +11,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -30,7 +25,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -44,7 +39,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -58,7 +53,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -74,7 +69,7 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
@@ -94,7 +89,11 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
bundle:
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||
uses: ./.github/workflows/bundle.yml
|
||||
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
AUTH_HTTPS_ONLY: false
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
|
||||
@@ -6,53 +6,30 @@ jobs:
|
||||
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
npm: true
|
||||
|
||||
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
bundle:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
|
||||
poetry:
|
||||
uses: ./.github/workflows/poetry.yml
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
node-version: ["24.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
@@ -6,6 +6,7 @@ __pycache__
|
||||
*$py.class
|
||||
.mypy_cache
|
||||
.vscode
|
||||
.codex
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.10
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
|
||||
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -66,6 +66,7 @@ test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
rm -rf ./tests/data \
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
|
||||
@@ -2,15 +2,17 @@ ARG LNBITS_TAG=latest
|
||||
|
||||
FROM lnbits/lnbits:${LNBITS_TAG}
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
|
||||
RUN apt-get update && apt-get -y upgrade
|
||||
RUN apt-get install -y netcat-openbsd npm nodejs git
|
||||
RUN apt-get install -y ca-certificates curl gnupg netcat-openbsd git nodejs
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# install sparksidecar
|
||||
RUN git clone https://github.com/lnbits/spark_sidecar
|
||||
RUN cd spark_sidecar && npm install
|
||||
RUN cd spark_sidecar && npm ci
|
||||
|
||||
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
|
||||
RUN uv sync --all-extras
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Generic OIDC Authentication Configuration
|
||||
|
||||
This document explains how to configure generic OIDC authentication for LNbits, which allows integration with various OIDC-compliant authentication providers such as Zitadel, Authentik, and others.
|
||||
|
||||
## Overview
|
||||
|
||||
The generic OIDC provider (`oidc`) complements the existing Keycloak provider and allows you to integrate any OIDC-compliant authentication service. You can customize the login button with your own organization name and icon.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following environment variables to your `.env` file or system environment:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```bash
|
||||
# Enable OIDC authentication
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
|
||||
# OIDC Discovery URL (well-known endpoint)
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
|
||||
# Client credentials from your OIDC provider
|
||||
LNBITS_OIDC_CLIENT_ID=your-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
### Optional Settings - Customize the Login Button
|
||||
|
||||
You can customize how the OIDC login button appears to your users:
|
||||
|
||||
```bash
|
||||
# Custom organization name (displayed on the login button)
|
||||
# Example: "Login via Zitadel" or "Login via Authentik"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
|
||||
# Custom icon URL (displayed on the login button)
|
||||
# Can be a full URL or a path to a local image
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON=https://zitadel.com/favicon.svg
|
||||
```
|
||||
|
||||
If not set, the button will display "Login via OIDC" with a generic lock icon.
|
||||
|
||||
## Zitadel Configuration Example
|
||||
|
||||
For Zitadel, configure as follows:
|
||||
|
||||
1. Create a new application in Zitadel
|
||||
2. Choose "Web" application type
|
||||
3. Configure the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Save the Client ID and Client Secret
|
||||
5. Use these environment variables:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-zitadel-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# Customize the button to show "Login via Zitadel" with Zitadel's logo
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON="https://zitadel.com/favicon.svg"
|
||||
```
|
||||
|
||||
**Result**: The login page will display a button with the text "Login via Zitadel" and the Zitadel logo.
|
||||
|
||||
## Authentik Configuration Example
|
||||
|
||||
For Authentik:
|
||||
|
||||
1. Create a new OAuth2/OpenID Provider
|
||||
2. Set the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
3. Configure scopes: `openid`, `email`, `profile`
|
||||
4. Get the Client ID and Client Secret
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/your-app/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-authentik-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Authentik"
|
||||
```
|
||||
|
||||
## Multiple Auth Methods
|
||||
|
||||
You can enable multiple authentication methods simultaneously:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=username-password,oidc-auth,keycloak-auth
|
||||
```
|
||||
|
||||
## Discovery Endpoint Requirements
|
||||
|
||||
Your OIDC provider must expose a standard discovery endpoint (`.well-known/openid-configuration`) that includes:
|
||||
|
||||
- `authorization_endpoint`
|
||||
- `token_endpoint`
|
||||
- `userinfo_endpoint`
|
||||
- `jwks_uri` (JSON Web Key Set)
|
||||
|
||||
The OIDC implementation will automatically fetch these endpoints from the discovery URL.
|
||||
|
||||
## User Mapping
|
||||
|
||||
The OIDC provider maps user information from the OIDC userinfo endpoint:
|
||||
|
||||
- `sub` → User ID
|
||||
- `email` → Email address
|
||||
- `given_name` → First name
|
||||
- `family_name` → Last name
|
||||
- `name` or `preferred_username` → Display name
|
||||
- `picture` → Profile picture URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication fails
|
||||
|
||||
1. Verify the discovery URL is accessible
|
||||
2. Check that Client ID and Client Secret are correct
|
||||
3. Ensure redirect URI in your OIDC provider matches: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Check LNbits logs for detailed error messages
|
||||
|
||||
### User info not populated
|
||||
|
||||
Some OIDC providers may use different claim names. If user information is not correctly populated, check your provider's userinfo endpoint response format and adjust the provider class if needed.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Keep client secrets secure and never commit them to version control
|
||||
- Use environment variables or secure configuration management
|
||||
- Regularly rotate client secrets
|
||||
- Review OIDC provider's security best practices
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The OIDC provider is implemented in `lnbits/core/models/sso/oidc.py` and extends the `fastapi_sso` library's `SSOBase` class. It uses the standard OpenID Connect flow with:
|
||||
|
||||
- Scopes: `openid`, `email`, `profile`
|
||||
- Response type: `code` (authorization code flow)
|
||||
- Discovery document for automatic endpoint resolution
|
||||
@@ -468,7 +468,12 @@ def register_async_tasks() -> None:
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(
|
||||
run_interval(
|
||||
settings.lnbits_funding_source_pending_interval_seconds,
|
||||
check_pending_payments,
|
||||
)
|
||||
)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
@@ -12,6 +12,7 @@ from .extensions import (
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_installed_extensions_count,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
@@ -58,6 +59,7 @@ from .users import (
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_accounts_count,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
@@ -117,11 +119,13 @@ __all__ = [
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_accounts_count",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_installed_extensions_count",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
|
||||
@@ -90,6 +90,13 @@ async def get_installed_extensions(
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM installed_extensions"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
|
||||
@@ -149,14 +149,12 @@ async def get_payments_paginated( # noqa: C901
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(
|
||||
f"""
|
||||
clause.append(f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
elif failed:
|
||||
@@ -346,14 +344,12 @@ async def get_payments_history(
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
where = [
|
||||
f"""
|
||||
where = [f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
]
|
||||
"""]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
|
||||
@@ -105,7 +105,8 @@ async def get_settings_field(
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||
value = json.loads(row["value"]) if row["value"] else None
|
||||
return SettingsField(id=row["id"], value=value, tag=row["tag"])
|
||||
|
||||
|
||||
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
||||
|
||||
@@ -4,7 +4,12 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallets,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
@@ -32,6 +37,13 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def get_accounts_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM accounts"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def update_account(account: Account, conn: Connection | None = None) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("accounts", account)
|
||||
@@ -52,17 +64,22 @@ async def get_accounts(
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
filters = filters or Filters()
|
||||
|
||||
# Make wallet filter explicit
|
||||
wallet_filter = (
|
||||
next((f for f in filters.filters if f.field == "wallet_id"), None)
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
if filters and wallet_filter and wallet_filter.values:
|
||||
where_clauses.append("wallets.id = :wallet_id")
|
||||
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
|
||||
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
|
||||
wallet_filter = filters.get_filter_by_field("wallet_id")
|
||||
|
||||
if wallet_filter and wallet_filter.values:
|
||||
wallet_id_value = next(iter(wallet_filter.values.values()), None)
|
||||
wallet = (
|
||||
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn)
|
||||
if wallet_id_value
|
||||
else None
|
||||
)
|
||||
if not wallet:
|
||||
return Page(data=[], total=0)
|
||||
where_clauses.append("accounts.id = :account_id")
|
||||
values = {**values, "account_id": wallet.user}
|
||||
filters.remove_filter_by_field("wallet_id")
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
|
||||
@@ -10,31 +10,26 @@ from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
"user" TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
@@ -42,10 +37,8 @@ async def m001_initial(db: Connection):
|
||||
|
||||
UNIQUE ("user", extension)
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -53,10 +46,8 @@ async def m001_initial(db: Connection):
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
""")
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
@@ -67,11 +58,9 @@ async def m001_initial(db: Connection):
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -85,8 +74,7 @@ async def m001_initial(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
@@ -149,8 +137,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -164,8 +151,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
@@ -174,8 +160,7 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_check (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
@@ -183,19 +168,16 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_notify (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
@@ -262,19 +244,16 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -282,13 +261,11 @@ async def m009_create_tinyurl_table(db: Connection):
|
||||
wallet TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
@@ -299,8 +276,7 @@ async def m010_create_installed_extensions_table(db: Connection):
|
||||
active BOOLEAN DEFAULT false,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
@@ -309,23 +285,19 @@ async def m011_optimize_balances_view(db: Connection):
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
@@ -345,15 +317,13 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
"""
|
||||
result = await db.execute("""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
""")
|
||||
rows = result.mappings().all()
|
||||
|
||||
for row in rows:
|
||||
@@ -386,8 +356,7 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
@@ -396,8 +365,7 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
@@ -484,8 +452,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -495,8 +462,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
@@ -505,8 +471,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
Important for querying whole lnbits balances.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -516,8 +481,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db: Connection):
|
||||
@@ -536,8 +500,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -549,8 +512,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
@@ -581,8 +543,7 @@ async def m024_drop_pending(db: Connection):
|
||||
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet_id,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -594,8 +555,7 @@ async def m025_refresh_view(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet_id
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m026_update_payment_table(db: Connection):
|
||||
@@ -658,8 +618,7 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
|
||||
async def m028_update_settings(db: Connection):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
@@ -667,8 +626,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
@@ -691,8 +649,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
component TEXT,
|
||||
ip_address TEXT,
|
||||
@@ -706,16 +663,13 @@ async def m029_create_audit_table(db: Connection):
|
||||
delete_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m030_add_user_api_tokens_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
@@ -738,32 +692,25 @@ async def m033_update_payment_table(db: Connection):
|
||||
|
||||
|
||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -776,16 +723,13 @@ async def m037_create_assets_table(db: Connection):
|
||||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m039_index_payments(db: Connection):
|
||||
@@ -804,11 +748,9 @@ async def m039_index_payments(db: Connection):
|
||||
]
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_payments_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m040_index_wallets(db: Connection):
|
||||
@@ -825,11 +767,9 @@ async def m040_index_wallets(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m042_index_accounts(db: Connection):
|
||||
@@ -843,11 +783,9 @@ async def m042_index_accounts(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SSO authentication providers for LNbits"""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Generic OIDC SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||
|
||||
|
||||
class OidcSSO(SSOBase):
|
||||
"""Class providing login via Generic OIDC OAuth (e.g., Zitadel, Authentik, etc.)"""
|
||||
|
||||
provider = "oidc"
|
||||
scope = ["openid", "email", "profile"]
|
||||
discovery_url = ""
|
||||
|
||||
async def openid_from_response(
|
||||
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||
) -> OpenID:
|
||||
"""Return OpenID from user information provided by OIDC provider"""
|
||||
return OpenID(
|
||||
email=response.get("email", ""),
|
||||
provider=self.provider,
|
||||
id=response.get("sub"),
|
||||
first_name=response.get("given_name"),
|
||||
last_name=response.get("family_name"),
|
||||
display_name=response.get("name") or response.get("preferred_username"),
|
||||
picture=response.get("picture"),
|
||||
)
|
||||
|
||||
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||
"""Get document containing handy urls"""
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await session.get(self.discovery_url)
|
||||
content = response.json()
|
||||
|
||||
return content
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
@@ -6,4 +8,4 @@ class TinyURL(BaseModel):
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -9,6 +9,7 @@ from lnbits.core.crud import (
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
get_installed_extensions_count,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
@@ -38,6 +39,8 @@ async def install_extension(
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
@@ -63,6 +66,15 @@ async def install_extension(
|
||||
return extension
|
||||
|
||||
|
||||
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
|
||||
if settings.lnbits_max_extensions == 0 or installed_ext:
|
||||
return
|
||||
|
||||
extensions_count = await get_installed_extensions_count()
|
||||
if extensions_count >= settings.lnbits_max_extensions:
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
@@ -509,6 +509,8 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
from lnbits.tasks import internal_invoice_queue_put
|
||||
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
@@ -819,6 +821,8 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
from lnbits.tasks import create_task
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
@@ -1068,3 +1072,30 @@ async def _send_payment_notification_in_background(
|
||||
if not wallet:
|
||||
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
|
||||
async def update_invoice_callback(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
|
||||
or internal_invoice_queue. Checks its status, updates and returns it.
|
||||
returns None if no payment was found or it not and incoming payment.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
return None
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
return payment
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_accounts_count,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
@@ -55,6 +56,8 @@ async def create_user_account_no_ckeck(
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
await check_users_limit(conn)
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(
|
||||
@@ -95,6 +98,15 @@ async def create_user_account_no_ckeck(
|
||||
return user
|
||||
|
||||
|
||||
async def check_users_limit(conn: Connection | None = None):
|
||||
if settings.lnbits_max_users == 0:
|
||||
return
|
||||
|
||||
users_count = await get_accounts_count(conn=conn)
|
||||
if users_count >= settings.lnbits_max_users:
|
||||
raise ValueError("Max amount of users have been created")
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
@@ -173,6 +185,12 @@ async def check_admin_settings():
|
||||
if account and account.extra and account.extra.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
if settings.has_first_install_token_changed():
|
||||
logger.warning("First install token is changed. Resetting admin settings.")
|
||||
new_settings = await init_admin_settings()
|
||||
settings.super_user = new_settings.super_user
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.crud.users import (
|
||||
get_user_access_control_lists,
|
||||
update_user_access_control_list,
|
||||
@@ -154,9 +155,20 @@ async def impersonate_user(
|
||||
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
|
||||
"admin_access_token",
|
||||
cookie_access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_impersonated",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
|
||||
return response
|
||||
|
||||
|
||||
@@ -177,7 +189,12 @@ async def stop_impersonate_user(
|
||||
)
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
admin_access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.delete_cookie("admin_access_token")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
@@ -532,6 +549,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
account.hash_password(data.password)
|
||||
await update_account(account)
|
||||
settings.first_install = False
|
||||
|
||||
# only confrm it after the super user has been successfully updated
|
||||
if settings.first_install_token:
|
||||
settings.first_install_token_confirmed = data.first_install_token
|
||||
await set_settings_field(
|
||||
"first_install_token_confirmed", data.first_install_token
|
||||
)
|
||||
return _auth_success_response(account.username, account.id, account.email)
|
||||
|
||||
|
||||
@@ -560,7 +584,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
||||
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
|
||||
)
|
||||
await create_user_account(account)
|
||||
return _auth_redirect_response(redirect_path, email)
|
||||
return _auth_redirect_response(redirect_path, account.id, email)
|
||||
|
||||
|
||||
def _auth_success_response(
|
||||
@@ -575,9 +599,20 @@ def _auth_success_response(
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -594,15 +629,28 @@ def _auth_api_token_response(
|
||||
)
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(
|
||||
usr=user_id, sub="", email=email, auth_time=int(time())
|
||||
)
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
@@ -622,7 +670,10 @@ def _new_sso(provider: str) -> SSOBase | None:
|
||||
|
||||
sso_provider_class = _find_auth_provider_class(provider)
|
||||
sso_provider = sso_provider_class(
|
||||
client_id, client_secret, None, allow_insecure_http=True
|
||||
client_id,
|
||||
client_secret,
|
||||
None,
|
||||
allow_insecure_http=not settings.auth_https_only,
|
||||
)
|
||||
if (
|
||||
discovery_url
|
||||
|
||||
@@ -98,12 +98,16 @@ async def api_install_extension(data: CreateExtension):
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, AssertionError)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else f"Failed to install extension '{ext_info.id}'."
|
||||
f"({ext_info.installed_version})."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
status_code=(
|
||||
HTTPStatus.BAD_REQUEST
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ async def index(
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
"base.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
},
|
||||
@@ -211,7 +211,7 @@ async def index(
|
||||
@generic_router.get("/node/public")
|
||||
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
|
||||
async def index_public(request: Request) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
|
||||
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
|
||||
@@ -613,6 +613,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for page_filter in self.filters:
|
||||
page_filter.table_name = table_name
|
||||
|
||||
def get_filter_by_field(self, field: str) -> Filter[TFilterModel] | None:
|
||||
return next((f for f in self.filters if f.field == field), None)
|
||||
|
||||
def remove_filter_by_field(self, field: str) -> None:
|
||||
self.filters = [f for f in self.filters if f.field != field]
|
||||
|
||||
|
||||
class DbJsonEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
|
||||
@@ -55,7 +55,6 @@ def static_url_for(static: str, path: str) -> str:
|
||||
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
||||
folders = [
|
||||
"lnbits/templates",
|
||||
"lnbits/core/templates",
|
||||
settings.extension_builder_working_dir_path.as_posix(),
|
||||
]
|
||||
|
||||
|
||||
@@ -447,6 +447,7 @@ class SecuritySettings(LNbitsSettings):
|
||||
|
||||
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
|
||||
first_install_token_confirmed: str | None = Field(default=None)
|
||||
|
||||
def is_wallet_max_balance_exceeded(self, amount):
|
||||
return (
|
||||
@@ -732,6 +733,7 @@ class FundingSourcesSettings(
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
# It will not fail the payment, it will make it return pending after the timeout
|
||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
||||
lnbits_funding_source_pending_interval_seconds: int = Field(default=1800, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
@@ -796,6 +798,7 @@ class AuthMethods(Enum):
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
keycloak_auth = "keycloak-auth"
|
||||
oidc_auth = "oidc-auth"
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
@@ -806,6 +809,7 @@ class AuthMethods(Enum):
|
||||
AuthMethods.google_auth.value,
|
||||
AuthMethods.github_auth.value,
|
||||
AuthMethods.keycloak_auth.value,
|
||||
AuthMethods.oidc_auth.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -851,6 +855,14 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class OidcAuthSettings(LNbitsSettings):
|
||||
oidc_discovery_url: str = Field(default="")
|
||||
oidc_client_id: str = Field(default="")
|
||||
oidc_client_secret: str = Field(default="")
|
||||
oidc_client_custom_org: str | None = Field(default=None)
|
||||
oidc_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AuditSettings(LNbitsSettings):
|
||||
lnbits_audit_enabled: bool = Field(default=True)
|
||||
|
||||
@@ -958,6 +970,7 @@ class EditableSettings(
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
OidcAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -993,6 +1006,10 @@ class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
# Keep disabled by default so local HTTP installs continue to work unless
|
||||
# operators explicitly opt into HTTPS-only auth cookies.
|
||||
auth_https_only: bool = Field(default=False)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000, gt=0)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
@@ -1010,11 +1027,20 @@ class EnvSettings(LNbitsSettings):
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
lnbits_max_users: int = Field(default=0, ge=0)
|
||||
lnbits_max_extensions: int = Field(default=0, ge=0)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
return self.lnbits_extensions_path == "lnbits"
|
||||
|
||||
def has_first_install_token_changed(self) -> bool:
|
||||
if not self.first_install_token:
|
||||
return False
|
||||
if not settings.first_install_token_confirmed:
|
||||
return False
|
||||
return self.first_install_token != settings.first_install_token_confirmed
|
||||
|
||||
def check_auth_secret_key(self):
|
||||
if self.auth_secret_key:
|
||||
return
|
||||
@@ -1174,6 +1200,8 @@ class PublicSettings(BaseModel):
|
||||
auth_methods: list[str] = Field(alias="authMethods")
|
||||
keycloak_org: str | None = Field(alias="keycloakOrg")
|
||||
keycloak_icon: str | None = Field(alias="keycloakIcon")
|
||||
oidc_org: str | None = Field(alias="oidcOrg")
|
||||
oidc_icon: str | None = Field(alias="oidcIcon")
|
||||
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
|
||||
has_nodemanager: bool = Field(alias="hasNodemanager")
|
||||
show_nodemanager: bool = Field(alias="showNodemanager")
|
||||
@@ -1238,6 +1266,8 @@ class PublicSettings(BaseModel):
|
||||
authMethods=settings.auth_allowed_methods,
|
||||
keycloakOrg=settings.keycloak_client_custom_org,
|
||||
keycloakIcon=settings.keycloak_client_custom_icon,
|
||||
oidcOrg=settings.oidc_client_custom_org,
|
||||
oidcIcon=settings.oidc_client_custom_icon,
|
||||
hasHoldinvoice=settings.has_holdinvoice,
|
||||
hasNodemanager=settings.has_nodemanager,
|
||||
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
|
||||
@@ -640,6 +640,16 @@ window.localisation.br = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Certifique-se de que a URL de retorno de chamada de autorização esteja definida para https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Segredo do Cliente Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organização Personalizada do Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de Descoberta do OIDC',
|
||||
auth_oidc_ci_label: 'ID do Cliente OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Certifique-se de que a URL de retorno de chamada de autorização esteja definida para https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Segredo do Cliente OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome da Organização Personalizada OIDC (ex. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Ícone Personalizado do OIDC (URL)',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
currency_settings: 'Configurações de Moeda',
|
||||
|
||||
@@ -353,6 +353,15 @@ window.localisation.cn = {
|
||||
auth_keycloak_ci_hint:
|
||||
'确保授权回调URL设置为https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak客户端密钥',
|
||||
auth_keycloak_custom_org_label: 'Keycloak 自定义组织',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak 自定义图标 (URL)',
|
||||
auth_oidc_label: 'OIDC 发现 URL',
|
||||
auth_oidc_ci_label: 'OIDC 客户端 ID',
|
||||
auth_oidc_ci_hint:
|
||||
'确保授权回调URL设置为https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC客户端密钥',
|
||||
auth_oidc_custom_org_label: 'OIDC 自定义组织名称(例如 Zitadel、Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC 自定义图标 (URL)',
|
||||
currency_settings: '货币设置',
|
||||
allowed_currencies: '允许的货币',
|
||||
allowed_currencies_hint: '限制可用法定货币的数量',
|
||||
|
||||
@@ -367,6 +367,16 @@ window.localisation.cs = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Ujistěte se, že je autorizace callback URL nastavena na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Klíč k aplikaci Keycloak tajemství',
|
||||
auth_keycloak_custom_org_label: 'Vlastní organizace Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Vlastní ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'URL pro zjištění OIDC',
|
||||
auth_oidc_ci_label: 'ID klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Ujistěte se, že je autorizace callback URL nastavena na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Klíč k aplikaci OIDC tajemství',
|
||||
auth_oidc_custom_org_label:
|
||||
'Název vlastní organizace OIDC (např. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Vlastní ikona OIDC (URL)',
|
||||
currency_settings: 'Nastavení měny',
|
||||
allowed_currencies: 'Povolené měny',
|
||||
allowed_currencies_hint: 'Omezte počet dostupných fiat měn',
|
||||
|
||||
@@ -377,6 +377,16 @@ window.localisation.de = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Stellen Sie sicher, dass die Autorisierungs-Callback-URL auf https://{domain}/api/v1/auth/keycloak/token eingestellt ist.',
|
||||
auth_keycloak_cs_label: 'Keycloak-Client-Geheimnis',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Benutzerdefinierte Organisation',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Benutzerdefiniertes Symbol (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery-URL',
|
||||
auth_oidc_ci_label: 'OIDC-Client-ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Stellen Sie sicher, dass die Autorisierungs-Callback-URL auf https://{domain}/api/v1/auth/oidc/token eingestellt ist.',
|
||||
auth_oidc_cs_label: 'OIDC-Client-Geheimnis',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Benutzerdefinierter Organisationsname (z.B. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Benutzerdefiniertes Symbol (URL)',
|
||||
currency_settings: 'Währungseinstellungen',
|
||||
allowed_currencies: 'Erlaubte Währungen',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -611,6 +611,10 @@ window.localisation.en = {
|
||||
payment_timeouts: 'Payment Timeouts',
|
||||
payment_wait_time: 'Payment Wait Time',
|
||||
seconds: 'seconds',
|
||||
payment_pending_interval: 'Check payment interval (sec)',
|
||||
payment_pending_interval_desc: 'Interval to check pending payments',
|
||||
payment_pending_interval_tooltip:
|
||||
'Controls how often LNbits checks for pending payments to update their status. Higher values can reduce the load on the node and speed up the payment process, but it will take longer for pending payments to be updated.',
|
||||
payment_wait_time_desc:
|
||||
'Wait time before marking an outgoing payment as pending. Default: 5s; raise for slow-settling invoices.',
|
||||
payment_wait_time_tooltip:
|
||||
@@ -642,6 +646,14 @@ window.localisation.en = {
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery URL',
|
||||
auth_oidc_ci_label: 'OIDC Client ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Custom Organization Name (e.g., Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Custom Icon (URL)',
|
||||
currency_settings: 'Currency Settings',
|
||||
allowed_currencies: 'Allowed Currencies',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -379,6 +379,16 @@ window.localisation.es = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Asegúrate de que la URL de devolución de llamada de autorización esté configurada en https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Secreto del Cliente de Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organización personalizada de Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icono personalizado de Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de descubrimiento de OIDC',
|
||||
auth_oidc_ci_label: 'ID de cliente de OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Asegúrate de que la URL de devolución de llamada de autorización esté configurada en https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Secreto del Cliente de OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nombre de organización personalizada OIDC (ej. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Icono personalizado de OIDC (URL)',
|
||||
currency_settings: 'Configuración de moneda',
|
||||
allowed_currencies: 'Monedas permitidas',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -520,6 +520,14 @@ window.localisation.fi = {
|
||||
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
|
||||
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
|
||||
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
|
||||
auth_oidc_label: 'OIDC-discovery-URL',
|
||||
auth_oidc_ci_label: 'OIDC-asiakastunnus',
|
||||
auth_oidc_ci_hint:
|
||||
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC-asiakassalasana',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC mukautetun organisaation nimi (esim. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Valinnainen OIDC-kuvake (URL)',
|
||||
currency_settings: 'Valuutta-asetukset',
|
||||
allowed_currencies: 'Käytettävät valuutat',
|
||||
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
|
||||
|
||||
@@ -381,6 +381,16 @@ window.localisation.fr = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Assurez-vous que l'URL de rappel d'autorisation est définie sur https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Secret client Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organisation personnalisée Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icône personnalisée Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de découverte OIDC',
|
||||
auth_oidc_ci_label: 'ID Client OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Assurez-vous que l'URL de rappel d'autorisation est définie sur https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'Secret client OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
"Nom de l'organisation personnalisée OIDC (par ex. Zitadel, Authentik)",
|
||||
auth_oidc_custom_icon_label: 'Icône personnalisée OIDC (URL)',
|
||||
currency_settings: 'Paramètres de devise',
|
||||
allowed_currencies: 'Devises autorisées',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -378,6 +378,16 @@ window.localisation.it = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Assicurati che l'URL di callback dell'autorizzazione sia impostato su https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Organizzazione personalizzata di Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icona personalizzata di Keycloak (URL)',
|
||||
auth_oidc_label: 'URL di individuazione di OIDC',
|
||||
auth_oidc_ci_label: 'ID client di OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Assicurati che l'URL di callback dell'autorizzazione sia impostato su https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome organizzazione personalizzata OIDC (es. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Icona personalizzata di OIDC (URL)',
|
||||
currency_settings: 'Impostazioni valuta',
|
||||
allowed_currencies: 'Valute consentite',
|
||||
allowed_currencies_hint: 'Limita il numero di valute fiat disponibili',
|
||||
|
||||
@@ -369,6 +369,15 @@ window.localisation.jp = {
|
||||
auth_keycloak_ci_hint:
|
||||
'認証コールバックURLが https://{domain}/api/v1/auth/keycloak/token に設定されていることを確認してください。',
|
||||
auth_keycloak_cs_label: 'キークローククライアントシークレット',
|
||||
auth_keycloak_custom_org_label: 'Keycloak カスタム組織',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak カスタムアイコン (URL)',
|
||||
auth_oidc_label: 'OIDC ディスカバリー URL',
|
||||
auth_oidc_ci_label: 'OIDC クライアント ID',
|
||||
auth_oidc_ci_hint:
|
||||
'認証コールバックURLが https://{domain}/api/v1/auth/oidc/token に設定されていることを確認してください。',
|
||||
auth_oidc_cs_label: 'OIDC クライアントシークレット',
|
||||
auth_oidc_custom_org_label: 'OIDC カスタム組織名(例:Zitadel、Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC カスタムアイコン (URL)',
|
||||
currency_settings: '通貨設定',
|
||||
allowed_currencies: '許可されている通貨',
|
||||
allowed_currencies_hint: '利用可能な法定通貨の数を制限する',
|
||||
|
||||
@@ -365,6 +365,16 @@ window.localisation.kr = {
|
||||
auth_keycloak_ci_hint:
|
||||
'승인 콜백 URL이 https://{domain}/api/v1/auth/keycloak/token으로 설정되어 있는지 확인하십시오.',
|
||||
auth_keycloak_cs_label: 'Keycloak 클라이언트 시크릿',
|
||||
auth_keycloak_custom_org_label: 'Keycloak 사용자 정의 조직',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak 사용자 정의 아이콘 (URL)',
|
||||
auth_oidc_label: 'OIDC 디스커버리 URL',
|
||||
auth_oidc_ci_label: 'OIDC 클라이언트 ID',
|
||||
auth_oidc_ci_hint:
|
||||
'승인 콜백 URL이 https://{domain}/api/v1/auth/oidc/token으로 설정되어 있는지 확인하십시오.',
|
||||
auth_oidc_cs_label: 'OIDC 클라이언트 시크릿',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC 사용자 정의 조직 이름 (예: Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC 사용자 정의 아이콘 (URL)',
|
||||
currency_settings: '통화 설정',
|
||||
allowed_currencies: '허용되는 통화',
|
||||
allowed_currencies_hint: '사용 가능한 법정 화폐의 수를 제한하십시오.',
|
||||
|
||||
@@ -377,6 +377,16 @@ window.localisation.nl = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Zorg ervoor dat de autorisatie callback-URL is ingesteld op https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak Clientgeheim',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Aangepaste Organisatie',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Aangepast Pictogram (URL)',
|
||||
auth_oidc_label: 'OIDC Ontdekking URL',
|
||||
auth_oidc_ci_label: 'OIDC-client-ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Zorg ervoor dat de autorisatie callback-URL is ingesteld op https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC Clientgeheim',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Aangepaste Organisatienaam (bijv. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Aangepast Pictogram (URL)',
|
||||
currency_settings: 'Valuta-instellingen',
|
||||
allowed_currencies: "Toegestane valuta's",
|
||||
allowed_currencies_hint: "Beperk het aantal beschikbare fiatvaluta's",
|
||||
|
||||
@@ -371,6 +371,16 @@ window.localisation.pi = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Make sure thant th' authorization callback URL be set t' https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery URL',
|
||||
auth_oidc_ci_label: 'OIDC Client ID',
|
||||
auth_oidc_ci_hint:
|
||||
"Make sure thant th' authorization callback URL be set t' https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Custom Organization Name (e.g., Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Custom Icon (URL)',
|
||||
currency_settings: "Doubloon Settin's",
|
||||
allowed_currencies: "Allo'ed Doubloons",
|
||||
allowed_currencies_hint: 'Limit the number of available fiat doubloons',
|
||||
|
||||
@@ -372,6 +372,16 @@ window.localisation.pl = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Upewnij się, że URL zwrotu autoryzacji jest ustawiony na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Hasło klienta Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Własna organizacja Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Własna ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'Adres URL Discovery OIDC',
|
||||
auth_oidc_ci_label: 'Identyfikator klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Upewnij się, że URL zwrotu autoryzacji jest ustawiony na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Hasło klienta OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nazwa własnej organizacji OIDC (np. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Własna ikona OIDC (URL)',
|
||||
currency_settings: 'Ustawienia waluty',
|
||||
allowed_currencies: 'Dozwolone waluty',
|
||||
allowed_currencies_hint: 'Ogranicz liczbę dostępnych walut fiducjarnych',
|
||||
|
||||
@@ -375,6 +375,16 @@ window.localisation.pt = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Certifique-se de que o URL de retorno de chamada de autorização esteja definido como https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Segredo do Cliente do Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organização Personalizada do Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de Descoberta do OIDC',
|
||||
auth_oidc_ci_label: 'ID do Cliente do OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Certifique-se de que o URL de retorno de chamada de autorização esteja definido como https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Segredo do Cliente do OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome da Organização Personalizada OIDC (ex. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Ícone Personalizado do OIDC (URL)',
|
||||
currency_settings: 'Configurações de Moeda',
|
||||
allowed_currencies: 'Moedas Permitidas',
|
||||
allowed_currencies_hint: 'Limite o número de moedas fiduciárias disponíveis',
|
||||
|
||||
@@ -371,6 +371,16 @@ window.localisation.sk = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Uistite sa, že URL spätného volania autorizácie je nastavená na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Tajný kľúč klienta Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Vlastná organizácia Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Vlastná ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'URL zistenia OIDC',
|
||||
auth_oidc_ci_label: 'ID klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Uistite sa, že URL spätného volania autorizácie je nastavená na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Tajný kľúč klienta OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Názov vlastnej organizácie OIDC (napr. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Vlastná ikona OIDC (URL)',
|
||||
currency_settings: 'Nastavenia meny',
|
||||
allowed_currencies: 'Povolené meny',
|
||||
allowed_currencies_hint: 'Obmedzte počet dostupných fiat mien',
|
||||
|
||||
@@ -370,6 +370,16 @@ window.localisation.we = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Gwnewch yn siŵr bod URL adalw awdurdodiad wedi'i osod i https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Cyfrinach Cleient Keycloak',
|
||||
auth_keycloak_custom_org_label: "Sefydliad Wedi'i Addasu Keycloak",
|
||||
auth_keycloak_custom_icon_label: "Eicon Wedi'i Addasu Keycloak (URL)",
|
||||
auth_oidc_label: 'URL Darganfod OIDC',
|
||||
auth_oidc_ci_label: 'ID Cleient OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Gwnewch yn siŵr bod URL adalw awdurdodiad wedi'i osod i https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'Cyfrinach Cleient OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
"Enw Sefydliad Wedi'i Addasu OIDC (e.e. Zitadel, Authentik)",
|
||||
auth_oidc_custom_icon_label: "Eicon Wedi'i Addasu OIDC (URL)",
|
||||
currency_settings: 'Gosodiadau Arian Cyfred',
|
||||
allowed_currencies: 'Ariannau a Ganiateir',
|
||||
allowed_currencies_hint: 'Cyfyngu nifer yr arian cyfred fiat sydd ar gael',
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Background circle -->
|
||||
<circle cx="50" cy="50" r="48" fill="#4A90E2" stroke="#2E5F8E" stroke-width="2"/>
|
||||
|
||||
<!-- Lock body -->
|
||||
<rect x="35" y="45" width="30" height="25" rx="2" fill="#FFFFFF"/>
|
||||
|
||||
<!-- Lock shackle -->
|
||||
<path d="M 40 45 L 40 35 Q 40 25 50 25 Q 60 25 60 35 L 60 45"
|
||||
fill="none" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- Keyhole -->
|
||||
<circle cx="50" cy="55" r="3" fill="#4A90E2"/>
|
||||
<rect x="48.5" y="55" width="3" height="8" fill="#4A90E2"/>
|
||||
|
||||
<!-- ID letters -->
|
||||
<text x="50" y="85" font-family="Arial, sans-serif" font-size="12" font-weight="bold"
|
||||
fill="#FFFFFF" text-anchor="middle">ID</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -442,7 +442,8 @@ window.app.component('username-password', {
|
||||
'nostr-auth-nip98',
|
||||
'google-auth',
|
||||
'github-auth',
|
||||
'keycloak-auth'
|
||||
'keycloak-auth',
|
||||
'oidc-auth'
|
||||
],
|
||||
username: this.userName,
|
||||
password: this.password_1,
|
||||
@@ -691,12 +692,10 @@ window.app.component('lnbits-node-qrcode', {
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<div style="text-align: center">
|
||||
<vue-qrcode
|
||||
:value="info.addresses[0]"
|
||||
:options="{width: 250}"
|
||||
<lnbits-qrcode
|
||||
v-if='info.addresses[0]'
|
||||
class="rounded-borders"
|
||||
></vue-qrcode>
|
||||
:value="info.addresses[0]"
|
||||
></lnbits-qrcode>
|
||||
<div v-else class='text-subtitle1'>
|
||||
No addresses available
|
||||
</div>
|
||||
|
||||
@@ -165,5 +165,26 @@ window.app.component('lnbits-qrcode', {
|
||||
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
|
||||
this.$refs.qrCode.$el.setAttribute('width', '100%')
|
||||
this.$refs.qrCode.$el.removeAttribute('height')
|
||||
},
|
||||
computed: {
|
||||
optimizedValue() {
|
||||
const separatorIndex = this.value.indexOf(':')
|
||||
const type =
|
||||
separatorIndex === -1 ? '' : this.value.substring(0, separatorIndex)
|
||||
const value =
|
||||
separatorIndex === -1
|
||||
? this.value
|
||||
: this.value.substring(separatorIndex + 1)
|
||||
|
||||
if (this.utils.isValidBech32(value)) {
|
||||
const normalizedValue = value.toUpperCase()
|
||||
if (type) {
|
||||
return `${type.toUpperCase()}:${normalizedValue}`
|
||||
}
|
||||
return normalizedValue
|
||||
}
|
||||
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ window.PageAccount = {
|
||||
name: 'bitcoin',
|
||||
color: 'deep-orange'
|
||||
},
|
||||
{
|
||||
name: 'classic',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'mint',
|
||||
color: 'green'
|
||||
|
||||
@@ -160,6 +160,46 @@ window._lnbitsUtils = {
|
||||
return null
|
||||
}
|
||||
},
|
||||
isValidBech32(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value.trim()
|
||||
if (
|
||||
!candidate ||
|
||||
(candidate !== candidate.toLowerCase() &&
|
||||
candidate !== candidate.toUpperCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = candidate.toLowerCase()
|
||||
const splitPosition = normalized.lastIndexOf('1')
|
||||
if (splitPosition <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const humanReadablePart = normalized.substring(0, splitPosition)
|
||||
const data = normalized.substring(splitPosition + 1)
|
||||
if (data.length < 6) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
typeof bech32ToFiveBitArray !== 'function' ||
|
||||
typeof verify_checksum !== 'function'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const words = bech32ToFiveBitArray(data)
|
||||
if (words.some(word => word < 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return verify_checksum(humanReadablePart, words)
|
||||
},
|
||||
async notifyApiError(error) {
|
||||
if (!error.response) {
|
||||
return console.error(error)
|
||||
|
||||
@@ -5,12 +5,8 @@ from collections.abc import Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
get_standalone_payment,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentState
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services.payments import update_invoice_callback
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
@@ -116,7 +112,10 @@ async def internal_invoice_listener() -> None:
|
||||
while settings.lnbits_running:
|
||||
checking_id = await internal_invoice_queue.get()
|
||||
logger.info(f"got an internal payment notification {checking_id}")
|
||||
await invoice_callback_dispatcher(checking_id, is_internal=True)
|
||||
payment = await update_invoice_callback(checking_id)
|
||||
if payment:
|
||||
logger.success(f"internal invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
|
||||
|
||||
async def invoice_listener() -> None:
|
||||
@@ -129,7 +128,10 @@ async def invoice_listener() -> None:
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
await invoice_callback_dispatcher(checking_id)
|
||||
payment = await update_invoice_callback(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
|
||||
|
||||
def wait_for_paid_invoices(
|
||||
@@ -165,33 +167,7 @@ def run_interval(
|
||||
return wrapper
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
|
||||
"""
|
||||
Takes an incoming payment, checks its status, and dispatches it to
|
||||
invoice_listeners from core and extensions.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
return
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
return
|
||||
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
internal = "internal" if is_internal else ""
|
||||
logger.success(f"{internal} invoice {checking_id} settled")
|
||||
async def invoice_callback_dispatcher(payment: Payment):
|
||||
for name, send_chan in invoice_listeners.items():
|
||||
logger.trace(f"invoice listeners: sending to `{name}`")
|
||||
await send_chan.put(payment)
|
||||
|
||||
@@ -82,9 +82,8 @@
|
||||
<!-- scripts libraries -->
|
||||
{% for url in INCLUDED_JS %}
|
||||
<script src="{{ static_url_for('static', url) }}"></script>
|
||||
{% endfor %}
|
||||
{% endfor %} {% if user %}
|
||||
<!-- user init -->
|
||||
{% if user %}
|
||||
<script>
|
||||
window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }}));
|
||||
{% if not public %}
|
||||
@@ -92,6 +91,12 @@
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endif %}
|
||||
<!-- app init -->
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue'
|
||||
})
|
||||
</script>
|
||||
<!-- scripts from extensions -->
|
||||
{% block scripts %}{% endblock %}
|
||||
<!-- components js -->
|
||||
|
||||
@@ -1050,6 +1050,34 @@ include('components/lnbits-error.vue') %}
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="authMethods.includes('oidc-auth')"
|
||||
href="/api/v1/auth/oidc"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
class="btn-fixed-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.oidcIcon
|
||||
? g.settings.oidcIcon
|
||||
: utils.url_for('lnbits/static/images/generic-oidc-logo.svg')
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<span
|
||||
v-text="
|
||||
$t('signin_with_custom_org', {
|
||||
custom_org: g.settings.oidcOrg || 'OIDC'
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
@@ -75,7 +75,35 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mt-lg">
|
||||
<div class="col-12">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('routing_fee_reserve_calculations')"></span>
|
||||
@@ -160,33 +188,26 @@
|
||||
min="0"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>
|
||||
<span v-text="$t('payment_pending_interval')"></span>
|
||||
<sup>
|
||||
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
|
||||
<q-tooltip max-width="150px">
|
||||
<span v-text="$t('payment_pending_interval_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</sup>
|
||||
</p>
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_funding_source_pending_interval_seconds"
|
||||
v-model="formData.lnbits_funding_source_pending_interval_seconds"
|
||||
:label="$t('payment_pending_interval')"
|
||||
:hint="$t('payment_pending_interval_desc')"
|
||||
step="1"
|
||||
min="0"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
|
||||
@@ -192,6 +192,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('oidc-auth')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">OIDC Auth</strong>
|
||||
|
||||
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_discovery_url"
|
||||
:label="$t('auth_oidc_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_id"
|
||||
:label="$t('auth_oidc_ci_label')"
|
||||
:hint="$t('auth_oidc_ci_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_secret"
|
||||
type="password"
|
||||
:label="$t('auth_oidc_cs_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_custom_org"
|
||||
:label="$t('auth_oidc_custom_org_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_custom_icon"
|
||||
:label="$t('auth_oidc_custom_icon_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pa-none">
|
||||
<br />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<qrcode-vue
|
||||
ref="qrCode"
|
||||
:value="value"
|
||||
:value="optimizedValue"
|
||||
:margin="margin"
|
||||
:size="size"
|
||||
level="Q"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block
|
||||
page_container %}
|
||||
{% extends "base.html" %} {% block page_container %}
|
||||
<lnbits-error
|
||||
code="{{ status_code | safe }}"
|
||||
message="{{ message | safe }}"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{% macro window_vars(user) -%}
|
||||
<script>
|
||||
//Needed for Vue to create the app on first load (although called on every page, its only loaded once)
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin]
|
||||
})
|
||||
<script>
|
||||
// deprecated dont use window_vars anymore
|
||||
</script>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
<div
|
||||
v-if="
|
||||
'google-auth' in g.settings.authMethods ||
|
||||
'github-auth' in g.settings.authMethods
|
||||
'github-auth' in g.settings.authMethods ||
|
||||
'keycloak-auth' in g.settings.authMethods ||
|
||||
'oidc-auth' in g.settings.authMethods
|
||||
"
|
||||
class="col q-pa-sm text-h6"
|
||||
>
|
||||
@@ -310,6 +312,58 @@
|
||||
<div>GitHub</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
v-if="'keycloak-auth' in g.settings.authMethods"
|
||||
class="col q-pa-sm"
|
||||
>
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/keycloak?user_id=${g.user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.keycloakIcon
|
||||
? g.settings.keycloakIcon
|
||||
: '{{ static_url_for('static', 'images/keycloak-logo.png') }}'
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div
|
||||
v-text="g.settings.keycloakOrg || 'Keycloak'"
|
||||
></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
v-if="'oidc-auth' in g.settings.authMethods"
|
||||
class="col q-pa-sm"
|
||||
>
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/oidc?user_id=${g.user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.oidcIcon
|
||||
? g.settings.oidcIcon
|
||||
: '{{ static_url_for('static', 'images/generic-oidc-logo.svg') }}'
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div v-text="g.settings.oidcOrg || 'OIDC'"></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
:value="'LIGHTNING:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
:href="'lightning:' + selectedRelease.paymentRequest"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
@@ -836,7 +836,7 @@
|
||||
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
||||
<lnbits-qrcode
|
||||
:value="
|
||||
'lightning:' +
|
||||
'LIGHTNING:' +
|
||||
selectedExtension.payToEnable.paymentRequest.toUpperCase()
|
||||
"
|
||||
:href="
|
||||
@@ -1247,13 +1247,10 @@
|
||||
<q-dialog v-model="paymentDialog.show" position="top">
|
||||
<q-card class="q-pa-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
|
||||
<lnbits-qrcode
|
||||
:value="paymentDialog.invoice"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
<lnbits-qrcode
|
||||
:value="'LIGHTNING:' + paymentDialog.invoice.toUpperCase()"
|
||||
:href="'lightning:' + paymentDialog.invoice"
|
||||
></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<q-card-actions align="between">
|
||||
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
|
||||
|
||||
@@ -589,23 +589,16 @@
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a
|
||||
<lnbits-qrcode
|
||||
:href="
|
||||
'lightning:' +
|
||||
transactionDetailsDialog.data.bolt11
|
||||
"
|
||||
>
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="
|
||||
'lightning:' +
|
||||
transactionDetailsDialog.data.bolt11.toUpperCase()
|
||||
"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
:value="
|
||||
'LIGHTNING:' +
|
||||
transactionDetailsDialog.data.bolt11.toUpperCase()
|
||||
"
|
||||
></lnbits-qrcode>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@@ -698,25 +691,15 @@
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a
|
||||
<lnbits-qrcode
|
||||
:value="
|
||||
'LIGHTNING:' +
|
||||
props.row.bolt11.toUpperCase()
|
||||
"
|
||||
:href="
|
||||
'lightning:' + props.row.bolt11
|
||||
"
|
||||
>
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl"
|
||||
>
|
||||
<qrcode-vue
|
||||
:value="
|
||||
'lightning:' +
|
||||
props.row.bolt11.toUpperCase()
|
||||
"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
<lnbits-qrcode
|
||||
v-else
|
||||
:href="'lightning:' + receive.paymentReq"
|
||||
:value="'lightning:' + receive.paymentReq"
|
||||
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
|
||||
>
|
||||
</lnbits-qrcode>
|
||||
<div class="text-center">
|
||||
|
||||
@@ -15,7 +15,11 @@ from lnbits.settings import settings
|
||||
def log_server_info():
|
||||
logger.info("LNbits Info")
|
||||
if settings.first_install:
|
||||
logger.success("This is a fresh install of LNbits.")
|
||||
if settings.has_first_install_token_changed():
|
||||
logger.success("This is a first install token reset.")
|
||||
else:
|
||||
logger.success("This is a fresh install of LNbits.")
|
||||
|
||||
if settings.first_install_token:
|
||||
logger.success(
|
||||
f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. "
|
||||
|
||||
@@ -69,7 +69,7 @@ def decrypt_content(
|
||||
1:
|
||||
]
|
||||
# extract iv and content
|
||||
(encrypted_content_b64, iv_b64) = content.split("?iv=")
|
||||
encrypted_content_b64, iv_b64 = content.split("?iv=")
|
||||
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
|
||||
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||
# Decrypt
|
||||
|
||||
@@ -1498,9 +1498,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.5.2-rc2"
|
||||
version = "1.5.3"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" }
|
||||
readme = "README.md"
|
||||
dependencies = [
|
||||
"bech32==1.2.0",
|
||||
"click==8.3.1",
|
||||
"fastapi==0.116.1",
|
||||
"starlette==0.47.1",
|
||||
"httpx==0.27.2",
|
||||
"jinja2==3.1.6",
|
||||
"lnurl==0.8.3",
|
||||
"pydantic==1.10.26",
|
||||
"pyqrcode==1.2.1",
|
||||
"shortuuid==1.0.13",
|
||||
"sse-starlette==2.3.6",
|
||||
"typing-extensions==4.15.0",
|
||||
"uvicorn==0.40.0",
|
||||
"sqlalchemy==1.4.54",
|
||||
"aiosqlite==0.22.1",
|
||||
"asyncpg==0.31.0",
|
||||
"uvloop==0.22.1",
|
||||
"websockets==15.0.1",
|
||||
"loguru==0.7.3",
|
||||
"grpcio==1.76.0",
|
||||
"protobuf==6.33.2",
|
||||
"pyln-client==25.12",
|
||||
"pywebpush==2.2.0",
|
||||
"slowapi==0.1.9",
|
||||
"websocket-client==1.9.0",
|
||||
"pycryptodomex==3.23.0",
|
||||
"packaging==25.0",
|
||||
"bolt11==2.1.1",
|
||||
"pyjwt==2.10.1",
|
||||
"itsdangerous==2.2.0",
|
||||
"fastapi-sso==0.19.0",
|
||||
"bech32~=1.2.0",
|
||||
"click~=8.3.1",
|
||||
"fastapi~=0.116.1",
|
||||
"starlette~=0.47.1",
|
||||
"httpx~=0.27.2",
|
||||
"jinja2~=3.1.6",
|
||||
"lnurl~=0.10.0",
|
||||
"pydantic~=1.10.26",
|
||||
"pyqrcode~=1.2.1",
|
||||
"shortuuid~=1.0.13",
|
||||
"sse-starlette~=2.3.6",
|
||||
"typing-extensions~=4.15.0",
|
||||
"uvicorn~=0.40.0",
|
||||
"sqlalchemy~=1.4.54",
|
||||
"aiosqlite~=0.22.1",
|
||||
"asyncpg~=0.31.0",
|
||||
"uvloop~=0.22.1",
|
||||
"websockets~=15.0.1",
|
||||
"loguru~=0.7.3",
|
||||
"grpcio~=1.76.0",
|
||||
"protobuf~=6.33.5",
|
||||
"pyln-client~=25.12.0",
|
||||
"pywebpush~=2.2.0",
|
||||
"slowapi~=0.1.9",
|
||||
"websocket-client~=1.9.0",
|
||||
"pycryptodomex~=3.23.0",
|
||||
"packaging~=25.0.0",
|
||||
"bolt11~=2.1.1",
|
||||
"pyjwt~=2.12.0",
|
||||
"itsdangerous~=2.2.0",
|
||||
"fastapi-sso~=0.19.0",
|
||||
# needed for boltz, lnurldevice, watchonly extensions
|
||||
"embit==0.8.0",
|
||||
"embit~=0.8.0",
|
||||
# needed for scheduler extension
|
||||
"python-crontab==3.3.0",
|
||||
"pynostr==0.7.0",
|
||||
"python-multipart==0.0.21",
|
||||
"filetype==1.2.0",
|
||||
"nostr-sdk==0.44.0",
|
||||
"bcrypt==5.0.0",
|
||||
"jsonpath-ng==1.7.0",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"greenlet (>=3.3.0,<4.0.0)",
|
||||
"python-crontab~=3.3.0",
|
||||
"pynostr~=0.7.0",
|
||||
"python-multipart~=0.0.22",
|
||||
"filetype~=1.2.0",
|
||||
"nostr-sdk~=0.44.0",
|
||||
"bcrypt~=5.0.0",
|
||||
"jsonpath-ng~=1.7.0",
|
||||
"pillow~=12.1.0",
|
||||
"python-dotenv~=1.2.1",
|
||||
"greenlet~=3.3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -58,31 +58,32 @@ lnbits = "lnbits.server:main"
|
||||
lnbits-cli = "lnbits.commands:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
|
||||
liquid = ["wallycore==1.5.1"]
|
||||
migration = ["psycopg2-binary==2.9.11"]
|
||||
breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"]
|
||||
liquid = ["wallycore~=1.5.1"]
|
||||
migration = ["psycopg2-binary~=2.9.11"]
|
||||
wasm = ["wasmtime>=43.0.0"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.12.0,<26.0.0",
|
||||
"mypy==1.17.1",
|
||||
"types-protobuf>=6.32.1.20251210,<7.0.0",
|
||||
"pre-commit>=4.5.1,<5.0.0",
|
||||
"openapi-spec-validator>=0.7.2,<1.0.0",
|
||||
"ruff>=0.14.10,<1.0.0",
|
||||
"types-passlib>=1.7.7.20250602,<2.0.0",
|
||||
"openai>=2.14.0",
|
||||
"json5>=0.13.0,<1.0.0",
|
||||
"asgi-lifespan>=2.1.0,<3.0.0",
|
||||
"anyio>=4.12.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-md>=0.2.0,<0.3.0",
|
||||
"pytest-httpserver>=1.1.3,<2.0.0",
|
||||
"pytest-mock>=3.15.1,<4.0.0",
|
||||
"types-mock>=5.2.0.20250924,<6.0.0",
|
||||
"mock>=5.2.0,<6.0.0",
|
||||
"grpcio-tools>=1.76.0,<2.0.0"
|
||||
"black~=26.3.1",
|
||||
"mypy~=1.17.1",
|
||||
"types-protobuf~=6.32.1.20251210",
|
||||
"pre-commit~=4.5.1",
|
||||
"openapi-spec-validator~=0.7.2",
|
||||
"ruff~=0.14.10",
|
||||
"types-passlib~=1.7.7.20250602",
|
||||
"openai~=2.14.0",
|
||||
"json5~=0.13.0",
|
||||
"asgi-lifespan~=2.1.0",
|
||||
"anyio~=4.12.1",
|
||||
"pytest~=9.0.2",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-md~=0.2.0",
|
||||
"pytest-httpserver~=1.1.3",
|
||||
"pytest-mock~=3.15.1",
|
||||
"types-mock~=5.2.0.20250924",
|
||||
"mock~=5.2.0",
|
||||
"grpcio-tools~=1.76.0"
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import Settings
|
||||
|
||||
|
||||
@@ -49,3 +52,109 @@ async def test_admin_update_noneditable_settings(
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_audit_monitor_and_test_email(
|
||||
client: AsyncClient, superuser_token: str, mocker
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.views.admin_api.get_balance_delta",
|
||||
mocker.AsyncMock(
|
||||
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.admin_api.send_email_notification",
|
||||
mocker.AsyncMock(return_value={"status": "queued"}),
|
||||
)
|
||||
|
||||
audit = await client.get(
|
||||
"/admin/api/v1/audit",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert audit.status_code == 200
|
||||
assert audit.json()["lnbits_balance_sats"] == 21
|
||||
|
||||
monitor = await client.get(
|
||||
"/admin/api/v1/monitor",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert monitor.status_code == 200
|
||||
assert "invoice_listeners" in monitor.json()
|
||||
|
||||
test_email = await client.get(
|
||||
"/admin/api/v1/testemail",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert test_email.status_code == 200
|
||||
assert test_email.json()["status"] == "queued"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_partial_reset_restart_and_backup(
|
||||
client: AsyncClient,
|
||||
superuser_token: str,
|
||||
settings: Settings,
|
||||
tmp_path,
|
||||
):
|
||||
response = await client.patch(
|
||||
"/admin/api/v1/settings",
|
||||
json={"lnbits_site_title": "PATCHED TITLE"},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "Success"
|
||||
|
||||
default_value = await client.get(
|
||||
"/admin/api/v1/settings/default",
|
||||
params={"field_name": "lnbits_site_title"},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert default_value.status_code == 200
|
||||
assert "default_value" in default_value.json()
|
||||
|
||||
backup_path = Path("lnbits-backup.zip")
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
|
||||
try:
|
||||
data_folder = tmp_path / "backup_data"
|
||||
data_folder.mkdir(parents=True, exist_ok=True)
|
||||
(data_folder / "sample.txt").write_text("backup me")
|
||||
settings.lnbits_data_folder = str(data_folder)
|
||||
|
||||
backup = await client.get(
|
||||
"/admin/api/v1/backup",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert backup.status_code == 200
|
||||
assert backup.headers["content-type"] == "application/zip"
|
||||
assert backup.content.startswith(b"PK")
|
||||
assert backup_path.is_file()
|
||||
finally:
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
backup_path.unlink(missing_ok=True)
|
||||
|
||||
server_restart.clear()
|
||||
restart = await client.get(
|
||||
"/admin/api/v1/restart",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert restart.status_code == 200
|
||||
assert restart.json()["status"] == "Success"
|
||||
assert server_restart.is_set() is True
|
||||
server_restart.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_delete_settings_requires_superuser(
|
||||
client: AsyncClient, superuser_token: str
|
||||
):
|
||||
server_restart.clear()
|
||||
response = await client.delete(
|
||||
"/admin/api/v1/settings",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert server_restart.is_set() is True
|
||||
server_restart.clear()
|
||||
|
||||
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
|
||||
|
||||
# check api_payment() internal function call (NOT API): payment status
|
||||
@pytest.mark.anyio
|
||||
async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
|
||||
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to):
|
||||
# check the payment status
|
||||
response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
|
||||
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"])
|
||||
assert isinstance(response, dict)
|
||||
assert response["paid"] is True
|
||||
assert "details" in response
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.assets import get_user_asset
|
||||
from lnbits.core.services.assets import create_user_asset
|
||||
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_asset_api_upload_list_update_and_delete(
|
||||
client: AsyncClient,
|
||||
user_headers_from: dict[str, str],
|
||||
):
|
||||
upload = await client.post(
|
||||
"/api/v1/assets?public_asset=false",
|
||||
headers={"Authorization": user_headers_from["Authorization"]},
|
||||
files={"file": ("note.txt", b"hello world", "text/plain")},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
asset = upload.json()
|
||||
assert asset["name"] == "note.txt"
|
||||
assert asset["is_public"] is False
|
||||
|
||||
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
|
||||
assert page.status_code == 200
|
||||
assert any(item["id"] == asset["id"] for item in page.json()["data"])
|
||||
|
||||
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
|
||||
assert info.status_code == 200
|
||||
assert info.json()["name"] == "note.txt"
|
||||
|
||||
data = await client.get(
|
||||
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
|
||||
)
|
||||
assert data.status_code == 200
|
||||
assert data.content == b"hello world"
|
||||
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
|
||||
|
||||
updated = await client.put(
|
||||
f"/api/v1/assets/{asset['id']}",
|
||||
headers=user_headers_from,
|
||||
json={"name": "renamed.txt", "is_public": True},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["name"] == "renamed.txt"
|
||||
assert updated.json()["is_public"] is True
|
||||
|
||||
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
|
||||
assert public_data.status_code == 200
|
||||
assert public_data.content == b"hello world"
|
||||
|
||||
deleted = await client.delete(
|
||||
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["success"] is True
|
||||
|
||||
missing = await client.get(
|
||||
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
|
||||
client: AsyncClient,
|
||||
from_user,
|
||||
to_user,
|
||||
superuser_token: str,
|
||||
):
|
||||
private_asset = await create_user_asset(
|
||||
from_user.id,
|
||||
make_upload_file(
|
||||
get_png_bytes(),
|
||||
filename=f"private_{uuid4().hex[:8]}.png",
|
||||
content_type="image/png",
|
||||
),
|
||||
is_public=False,
|
||||
)
|
||||
other_user_headers = await get_user_token_headers(client, to_user.id)
|
||||
|
||||
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
|
||||
assert anonymous.status_code == 404
|
||||
|
||||
wrong_user = await client.get(
|
||||
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
|
||||
)
|
||||
assert wrong_user.status_code == 404
|
||||
|
||||
admin_updated = await client.put(
|
||||
f"/api/v1/assets/{private_asset.id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
json={"is_public": True, "name": "admin-visible.png"},
|
||||
)
|
||||
assert admin_updated.status_code == 200
|
||||
assert admin_updated.json()["is_public"] is True
|
||||
assert admin_updated.json()["name"] == "admin-visible.png"
|
||||
|
||||
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
|
||||
assert thumbnail.status_code == 200
|
||||
assert thumbnail.content
|
||||
assert thumbnail.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_asset_api_validates_uploads_and_missing_assets(
|
||||
client: AsyncClient,
|
||||
user_headers_from: dict[str, str],
|
||||
):
|
||||
invalid = await client.post(
|
||||
"/api/v1/assets",
|
||||
headers={"Authorization": user_headers_from["Authorization"]},
|
||||
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
|
||||
)
|
||||
assert invalid.status_code == 400
|
||||
assert "not allowed" in invalid.json()["detail"]
|
||||
|
||||
missing = await client.delete(
|
||||
f"/api/v1/assets/{uuid4().hex}",
|
||||
headers=user_headers_from,
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
|
||||
assert missing_thumb.status_code == 404
|
||||
|
||||
stored = await create_user_asset(
|
||||
"missing-user-check",
|
||||
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
|
||||
is_public=True,
|
||||
)
|
||||
fetched = await get_user_asset("missing-user-check", stored.id)
|
||||
assert fetched is not None
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.audit import create_audit_entry
|
||||
from lnbits.core.models import AuditEntry
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
|
||||
response = await client.get("/audit/api/v1", headers=user_headers_from)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_audit_api_returns_entries_and_stats(
|
||||
client: AsyncClient,
|
||||
superuser_token: str,
|
||||
):
|
||||
component = f"audit_component_{uuid4().hex[:8]}"
|
||||
await create_audit_entry(
|
||||
AuditEntry(
|
||||
component=component,
|
||||
ip_address="127.0.0.1",
|
||||
user_id=uuid4().hex,
|
||||
path="/api/v1/test",
|
||||
request_method="GET",
|
||||
response_code="200",
|
||||
duration=0.12,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await create_audit_entry(
|
||||
AuditEntry(
|
||||
component=component,
|
||||
ip_address="127.0.0.2",
|
||||
user_id=uuid4().hex,
|
||||
path="/api/v1/test",
|
||||
request_method="POST",
|
||||
response_code="400",
|
||||
duration=2.5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {superuser_token}"}
|
||||
|
||||
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
|
||||
assert page.status_code == 200
|
||||
page_data = page.json()
|
||||
assert page_data["total"] == 2
|
||||
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
|
||||
|
||||
stats = await client.get(
|
||||
f"/audit/api/v1/stats?component={component}",
|
||||
headers=headers,
|
||||
)
|
||||
assert stats.status_code == 200
|
||||
payload = stats.json()
|
||||
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
|
||||
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
|
||||
assert payload["component"][0]["field"] == component
|
||||
assert payload["long_duration"][0]["field"] == "/api/v1/test"
|
||||
@@ -0,0 +1,203 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import RedirectResponse
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.users import get_account, update_account
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.auth_api import get_account_by_email
|
||||
from lnbits.settings import Settings
|
||||
|
||||
|
||||
class _FakeSSO:
|
||||
def __init__(self, userinfo: object | None = None, state: str = ""):
|
||||
self.userinfo = userinfo
|
||||
self.state = state
|
||||
self.redirect_uri: str | None = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
async def get_login_redirect(self, state: str):
|
||||
self.state = state
|
||||
return RedirectResponse("https://example.com/sso/login")
|
||||
|
||||
async def verify_and_process(self, _request):
|
||||
return self.userinfo
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_api_logout_and_update_ui_customization(
|
||||
http_client: AsyncClient,
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
|
||||
response = await http_client.patch(
|
||||
f"/api/v1/auth/ui?usr={user.id}",
|
||||
json={"theme": "amber", "walletLayout": "grid"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ui_customization"]["theme"] == "amber"
|
||||
assert response.json()["ui_customization"]["walletLayout"] == "grid"
|
||||
|
||||
logout = await http_client.post("/api/v1/auth/logout")
|
||||
assert logout.status_code == 200
|
||||
assert logout.json()["status"] == "success"
|
||||
assert "cookie_access_token=" in logout.headers["set-cookie"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
|
||||
provider = "github"
|
||||
login_sso = _FakeSSO()
|
||||
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
|
||||
|
||||
response = await http_client.get(
|
||||
f"/api/v1/auth/{provider}", params={"user_id": user.id}
|
||||
)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "https://example.com/sso/login"
|
||||
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
|
||||
assert login_sso.state
|
||||
|
||||
email = f"sso_{uuid4().hex[:8]}@lnbits.com"
|
||||
callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="")
|
||||
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso)
|
||||
|
||||
callback = await http_client.get(f"/api/v1/auth/{provider}/token")
|
||||
assert callback.status_code == 307
|
||||
assert callback.headers["location"] == "/wallet"
|
||||
|
||||
account = await get_account_by_email(email, active_only=False)
|
||||
assert account is not None
|
||||
assert account.email == email
|
||||
assert account.extra.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_api_first_install_success_and_validation(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert superuser is not None
|
||||
|
||||
original_username = superuser.username
|
||||
original_password_hash = superuser.password_hash
|
||||
original_first_install = settings.first_install
|
||||
original_first_install_token = settings.first_install_token
|
||||
|
||||
first_install_token = f"install_{uuid4().hex[:8]}"
|
||||
new_username = f"reinstall_{uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
settings.first_install = True
|
||||
settings.first_install_token = first_install_token
|
||||
|
||||
missing_token = await http_client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": new_username,
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
},
|
||||
)
|
||||
assert missing_token.status_code == 401
|
||||
assert missing_token.json()["detail"] == "Missing first_install_token."
|
||||
|
||||
success = await http_client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": new_username,
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"first_install_token": first_install_token,
|
||||
},
|
||||
)
|
||||
assert success.status_code == 200
|
||||
assert success.json()["access_token"]
|
||||
assert "cookie_access_token=" in success.headers["set-cookie"]
|
||||
assert "Secure" not in success.headers["set-cookie"]
|
||||
|
||||
updated_superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert updated_superuser is not None
|
||||
assert updated_superuser.username == new_username
|
||||
assert settings.first_install is False
|
||||
|
||||
forbidden = await http_client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": f"blocked_{uuid4().hex[:8]}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == "This is not your first install"
|
||||
finally:
|
||||
restored_superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert restored_superuser is not None
|
||||
restored_superuser.username = original_username
|
||||
restored_superuser.password_hash = original_password_hash
|
||||
await update_account(restored_superuser)
|
||||
settings.first_install = original_first_install
|
||||
settings.first_install_token = original_first_install_token
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert superuser is not None
|
||||
|
||||
original_username = superuser.username
|
||||
original_password_hash = superuser.password_hash
|
||||
original_first_install = settings.first_install
|
||||
original_auth_https_only = settings.auth_https_only
|
||||
|
||||
new_username = f"secure_{uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
settings.first_install = True
|
||||
settings.auth_https_only = True
|
||||
|
||||
success = await http_client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": new_username,
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert success.status_code == 200
|
||||
assert "cookie_access_token=" in success.headers["set-cookie"]
|
||||
assert "Secure" in success.headers["set-cookie"]
|
||||
finally:
|
||||
restored_superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert restored_superuser is not None
|
||||
restored_superuser.username = original_username
|
||||
restored_superuser.password_hash = original_password_hash
|
||||
await update_account(restored_superuser)
|
||||
settings.first_install = original_first_install
|
||||
settings.auth_https_only = original_auth_https_only
|
||||
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.models import Account, CreateInvoice
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.callback_api import (
|
||||
handle_paypal_event,
|
||||
handle_stripe_event,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_callback_api_generic_webhook_handler_routes_providers(
|
||||
http_client: AsyncClient, mocker
|
||||
):
|
||||
stripe_mock = mocker.patch(
|
||||
"lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock()
|
||||
)
|
||||
paypal_mock = mocker.patch(
|
||||
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
|
||||
)
|
||||
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
|
||||
mocker.patch(
|
||||
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
|
||||
)
|
||||
|
||||
stripe = await http_client.post(
|
||||
"/api/v1/callback/stripe",
|
||||
headers={"Stripe-Signature": "sig"},
|
||||
json={"id": "evt_1", "type": "payment_intent.succeeded"},
|
||||
)
|
||||
assert stripe.status_code == 200
|
||||
assert stripe.json()["success"] is True
|
||||
stripe_mock.assert_awaited_once()
|
||||
|
||||
paypal = await http_client.post(
|
||||
"/api/v1/callback/paypal",
|
||||
json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"},
|
||||
)
|
||||
assert paypal.status_code == 200
|
||||
assert paypal.json()["success"] is True
|
||||
paypal_mock.assert_awaited_once()
|
||||
|
||||
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
|
||||
assert unknown.status_code == 200
|
||||
assert unknown.json()["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_callback_api_handles_paid_events_with_real_payments(mocker):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
payment = await create_wallet_invoice(
|
||||
wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback")
|
||||
)
|
||||
|
||||
fiat_status_mock = mocker.patch(
|
||||
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
|
||||
)
|
||||
|
||||
await handle_stripe_event(
|
||||
{
|
||||
"id": "evt_stripe",
|
||||
"type": "payment_intent.succeeded",
|
||||
"data": {
|
||||
"object": {
|
||||
"object": "payment_intent",
|
||||
"metadata": {"payment_hash": payment.payment_hash},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
await handle_paypal_event(
|
||||
{
|
||||
"id": "evt_paypal",
|
||||
"event_type": "CHECKOUT.ORDER.APPROVED",
|
||||
"resource": {
|
||||
"purchase_units": [{"invoice_id": payment.payment_hash}],
|
||||
},
|
||||
}
|
||||
)
|
||||
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
|
||||
|
||||
assert fiat_status_mock.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
payment = await create_wallet_invoice(
|
||||
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
|
||||
)
|
||||
|
||||
create_fiat_invoice_mock = mocker.patch(
|
||||
"lnbits.core.views.callback_api.create_fiat_invoice",
|
||||
mocker.AsyncMock(return_value=payment),
|
||||
)
|
||||
fiat_status_mock = mocker.patch(
|
||||
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
|
||||
)
|
||||
|
||||
await handle_stripe_event(
|
||||
{
|
||||
"id": "evt_invoice_paid",
|
||||
"type": "invoice.paid",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "invoice_1",
|
||||
"currency": "usd",
|
||||
"amount_paid": 500,
|
||||
"hosted_invoice_url": "https://stripe.example/invoice",
|
||||
"customer_email": "alice@example.com",
|
||||
"lines": {"data": [{"description": "Gold Plan"}]},
|
||||
"parent": {
|
||||
"type": "subscription_details",
|
||||
"subscription_details": {
|
||||
"metadata": {
|
||||
"alan_action": "subscription",
|
||||
"wallet_id": wallet.id,
|
||||
"tag": "gold",
|
||||
"memo": "Monthly Gold",
|
||||
"extra": json.dumps({"plan": "gold"}),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
create_fiat_invoice_mock.assert_awaited()
|
||||
fiat_status_mock.assert_awaited()
|
||||
|
||||
await handle_paypal_event(
|
||||
{
|
||||
"id": "evt_sale_completed",
|
||||
"event_type": "PAYMENT.SALE.COMPLETED",
|
||||
"resource": {
|
||||
"id": "sale_1",
|
||||
"billing_agreement_id": "agreement_1",
|
||||
"amount": {"currency": "USD", "total": "7.50"},
|
||||
"custom_id": json.dumps(
|
||||
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
assert create_fiat_invoice_mock.await_count == 2
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="PayPal subscription event missing custom metadata."
|
||||
):
|
||||
await handle_paypal_event(
|
||||
{
|
||||
"id": "evt_bad_sale",
|
||||
"event_type": "PAYMENT.SALE.COMPLETED",
|
||||
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,430 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
|
||||
from lnbits.core.crud.extensions import (
|
||||
create_installed_extension,
|
||||
get_installed_extension,
|
||||
get_user_extension,
|
||||
)
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.core.crud.wallets import create_wallet
|
||||
from lnbits.core.models import Account, CreateInvoice
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtensionInfo,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_activate_extension,
|
||||
api_deactivate_extension,
|
||||
api_disable_extension,
|
||||
api_enable_extension,
|
||||
api_extension_details,
|
||||
api_get_user_extensions,
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
api_update_pay_to_enable,
|
||||
create_extension_review,
|
||||
delete_extension_db,
|
||||
extensions,
|
||||
get_extension_release,
|
||||
get_extension_releases,
|
||||
get_extension_reviews,
|
||||
get_extension_reviews_tags,
|
||||
get_pay_to_enable_invoice,
|
||||
get_pay_to_install_invoice,
|
||||
)
|
||||
from tests.helpers import make_extension_release, make_installable_extension
|
||||
|
||||
|
||||
class _MockHTTPResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
json_data=None,
|
||||
text: str = "",
|
||||
status_code: int = 200,
|
||||
is_error: bool = False,
|
||||
):
|
||||
self._json_data = json_data
|
||||
self.text = text
|
||||
self.status_code = status_code
|
||||
self.is_error = is_error
|
||||
|
||||
def json(self):
|
||||
return self._json_data
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise ValueError(self.text or "request failed")
|
||||
|
||||
|
||||
class _MockHTTPClient:
|
||||
def __init__(self, responses: dict[str, _MockHTTPResponse]):
|
||||
self.responses = responses
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
async def get(self, url: str):
|
||||
return self.responses[url]
|
||||
|
||||
async def post(self, url: str, json=None):
|
||||
return self.responses[url]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_install_details_and_release_endpoints(mocker):
|
||||
ext_id = f"ext_{uuid4().hex[:8]}"
|
||||
release = make_extension_release(ext_id)
|
||||
create_data = CreateExtension(
|
||||
ext_id=ext_id,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
)
|
||||
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_extension_release",
|
||||
mocker.AsyncMock(return_value=release),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.install_extension",
|
||||
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
|
||||
)
|
||||
activate_mock = mocker.patch(
|
||||
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
|
||||
)
|
||||
|
||||
installed = await api_install_extension(create_data)
|
||||
assert installed.code == ext_id
|
||||
activate_mock.assert_awaited_once()
|
||||
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_extension_releases",
|
||||
mocker.AsyncMock(return_value=[release]),
|
||||
)
|
||||
mocker.patch.object(
|
||||
ExtensionRelease,
|
||||
"fetch_release_details",
|
||||
mocker.AsyncMock(return_value={"description": "Extension details"}),
|
||||
)
|
||||
details = await api_extension_details(ext_id, release.details_link or "")
|
||||
assert details["description"] == "Extension details"
|
||||
assert details["icon"] == release.icon
|
||||
assert details["repo"] == release.repo
|
||||
|
||||
installed_ext = make_installable_extension(
|
||||
ext_id,
|
||||
payments=[
|
||||
ReleasePaymentInfo(
|
||||
amount=55,
|
||||
pay_link=release.pay_link,
|
||||
payment_hash=f"payment_{uuid4().hex[:8]}",
|
||||
)
|
||||
],
|
||||
)
|
||||
await create_installed_extension(installed_ext)
|
||||
releases = await get_extension_releases(ext_id)
|
||||
assert releases[0].paid_sats == 55
|
||||
|
||||
config = ExtensionConfig(
|
||||
name=ext_id,
|
||||
short_description="Config",
|
||||
min_lnbits_version="0.1.0",
|
||||
max_lnbits_version=None,
|
||||
)
|
||||
mocker.patch.object(
|
||||
ExtensionConfig,
|
||||
"fetch_github_release_config",
|
||||
mocker.AsyncMock(return_value=config),
|
||||
)
|
||||
release_info = await get_extension_release("org", ext_id, "v1.0.0")
|
||||
assert release_info["is_version_compatible"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
|
||||
regular_user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
admin_account = await get_account(admin_user.id)
|
||||
assert admin_account is not None
|
||||
admin_wallet = await create_wallet(
|
||||
user_id=admin_account.id, wallet_name="extension sales"
|
||||
)
|
||||
|
||||
ext_id = f"paid_{uuid4().hex[:8]}"
|
||||
await create_installed_extension(
|
||||
make_installable_extension(
|
||||
ext_id,
|
||||
pay_to_enable=PayToEnableInfo(
|
||||
required=True, amount=10, wallet=admin_wallet.id
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
updated = await api_update_pay_to_enable(
|
||||
ext_id,
|
||||
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
|
||||
account=admin_account,
|
||||
)
|
||||
assert updated.success is True
|
||||
stored_extension = await get_installed_extension(ext_id)
|
||||
assert stored_extension is not None
|
||||
assert stored_extension.meta is not None
|
||||
assert stored_extension.meta.pay_to_enable is not None
|
||||
assert stored_extension.meta.pay_to_enable.amount == 21
|
||||
|
||||
enable_invoice = await create_wallet_invoice(
|
||||
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.create_invoice",
|
||||
mocker.AsyncMock(return_value=enable_invoice),
|
||||
)
|
||||
invoice_response = await get_pay_to_enable_invoice(
|
||||
ext_id,
|
||||
PayToEnableInfo(amount=21),
|
||||
account_id=AccountId(id=regular_user.id),
|
||||
)
|
||||
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
|
||||
|
||||
user_ext = await get_user_extension(regular_user.id, ext_id)
|
||||
assert user_ext is not None
|
||||
assert user_ext.extra is not None
|
||||
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_valid_extensions",
|
||||
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.check_transaction_status",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
|
||||
)
|
||||
|
||||
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
|
||||
assert enabled.success is True
|
||||
user_ext = await get_user_extension(regular_user.id, ext_id)
|
||||
assert user_ext is not None
|
||||
assert user_ext.active is True
|
||||
assert user_ext.extra == UserExtensionInfo(
|
||||
payment_hash_to_enable=enable_invoice.payment_hash,
|
||||
paid_to_enable=True,
|
||||
)
|
||||
|
||||
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
|
||||
assert disabled.success is True
|
||||
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
|
||||
assert disabled_again.success is True
|
||||
assert "already disabled" in disabled_again.message
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_valid_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
|
||||
Extension(code="other", is_valid=True),
|
||||
]
|
||||
),
|
||||
)
|
||||
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
|
||||
assert [ext.code for ext in visible_extensions] == [ext_id]
|
||||
|
||||
catalog_entry = make_installable_extension(
|
||||
ext_id,
|
||||
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_installable_extensions",
|
||||
mocker.AsyncMock(return_value=[catalog_entry]),
|
||||
)
|
||||
catalog = await extensions(AccountId(id=regular_user.id))
|
||||
catalog_item = next(item for item in catalog if item["id"] == ext_id)
|
||||
assert catalog_item["payToEnable"]["wallet"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
|
||||
base_ext = f"base_{uuid4().hex[:8]}"
|
||||
dependent_ext = f"dependent_{uuid4().hex[:8]}"
|
||||
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
|
||||
db_ext = f"db_{uuid4().hex[:8]}"
|
||||
|
||||
await create_installed_extension(make_installable_extension(base_ext))
|
||||
await create_installed_extension(
|
||||
make_installable_extension(dependent_ext, dependencies=[base_ext])
|
||||
)
|
||||
await create_installed_extension(make_installable_extension(uninstall_ext))
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_valid_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
Extension(code=base_ext, is_valid=True, name="Base"),
|
||||
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
|
||||
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException, match="depends on this one"):
|
||||
await api_uninstall_extension(base_ext)
|
||||
|
||||
uninstall_mock = mocker.patch(
|
||||
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
|
||||
)
|
||||
uninstalled = await api_uninstall_extension(uninstall_ext)
|
||||
assert uninstalled.success is True
|
||||
uninstall_mock.assert_awaited_once_with(uninstall_ext)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_valid_extension",
|
||||
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
|
||||
)
|
||||
activate_mock = mocker.patch(
|
||||
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
|
||||
)
|
||||
deactivate_mock = mocker.patch(
|
||||
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
|
||||
)
|
||||
activated = await api_activate_extension(base_ext)
|
||||
assert activated.success is True
|
||||
deactivated = await api_deactivate_extension(base_ext)
|
||||
assert deactivated.success is True
|
||||
activate_mock.assert_awaited_once()
|
||||
deactivate_mock.assert_awaited_once()
|
||||
|
||||
owner = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = owner.wallets[0]
|
||||
install_invoice = await create_wallet_invoice(
|
||||
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
|
||||
)
|
||||
release = make_extension_release(base_ext, version="2.0.0")
|
||||
payment_info = ReleasePaymentInfo(
|
||||
amount=33,
|
||||
pay_link=release.pay_link,
|
||||
payment_hash=install_invoice.payment_hash,
|
||||
payment_request=install_invoice.bolt11,
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_extension_release",
|
||||
mocker.AsyncMock(return_value=release),
|
||||
)
|
||||
mocker.patch.object(
|
||||
ExtensionRelease,
|
||||
"fetch_release_payment_info",
|
||||
mocker.AsyncMock(return_value=payment_info),
|
||||
)
|
||||
invoice = await get_pay_to_install_invoice(
|
||||
base_ext,
|
||||
CreateExtension(
|
||||
ext_id=base_ext,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
cost_sats=33,
|
||||
),
|
||||
)
|
||||
assert invoice.payment_hash == install_invoice.payment_hash
|
||||
|
||||
await update_migration_version(None, db_ext, 1)
|
||||
drop_mock = mocker.patch(
|
||||
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
|
||||
)
|
||||
deleted = await delete_extension_db(db_ext)
|
||||
assert deleted.success is True
|
||||
drop_mock.assert_awaited_once_with(ext_id=db_ext)
|
||||
assert await get_db_version(db_ext) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_review_endpoints(mocker):
|
||||
ext_id = f"review_{uuid4().hex[:8]}"
|
||||
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
|
||||
tags_url = f"{reviews_base}/tags"
|
||||
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
|
||||
create_review_url = f"{reviews_base}/reviews"
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": f"/api/v1/extension/reviews/{ext_id}",
|
||||
"query_string": b"offset=0&limit=5",
|
||||
"headers": [],
|
||||
}
|
||||
)
|
||||
mock_client = _MockHTTPClient(
|
||||
{
|
||||
tags_url: _MockHTTPResponse(
|
||||
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
|
||||
),
|
||||
reviews_url: _MockHTTPResponse(
|
||||
json_data={
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Alice",
|
||||
"tag": "good",
|
||||
"rating": 950,
|
||||
"comment": "solid",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
),
|
||||
create_review_url: _MockHTTPResponse(
|
||||
json_data={
|
||||
"payment_hash": f"hash_{uuid4().hex[:8]}",
|
||||
"payment_request": "lnbc1review",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
|
||||
)
|
||||
|
||||
tags = await get_extension_reviews_tags()
|
||||
assert tags[0].tag == "good"
|
||||
|
||||
reviews = await get_extension_reviews(ext_id, request)
|
||||
assert reviews.total == 1
|
||||
assert reviews.data[0].comment == "solid"
|
||||
|
||||
payment_request = await create_extension_review(
|
||||
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
|
||||
)
|
||||
assert payment_request.payment_hash.startswith("hash_")
|
||||
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
UserExtension,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.views.extensions_builder_api import (
|
||||
api_build_extension,
|
||||
api_delete_extension_builder_data,
|
||||
api_deploy_extension,
|
||||
api_preview_extension,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_extension_data, make_extension_release
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extensions_builder_api_build_preview_and_cleanup(
|
||||
tmp_path, settings: Settings, mocker, from_user
|
||||
):
|
||||
ext_id = f"builder_{uuid4().hex[:8]}"
|
||||
data = make_extension_data(ext_id)
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "index.txt").write_text("hello")
|
||||
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
build_mock = mocker.patch(
|
||||
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
|
||||
mocker.AsyncMock(
|
||||
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
|
||||
),
|
||||
)
|
||||
clean_mock = mocker.patch(
|
||||
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path)
|
||||
|
||||
build_response = await api_build_extension(data)
|
||||
assert Path(build_response.path).is_file()
|
||||
assert build_response.filename == f"{ext_id}.zip"
|
||||
|
||||
preview = await api_preview_extension(data, AccountId(id=from_user.id))
|
||||
assert preview.success is True
|
||||
assert ext_id in preview.message
|
||||
|
||||
cleaned = await api_delete_extension_builder_data()
|
||||
assert cleaned.success is True
|
||||
clean_mock.assert_called_once()
|
||||
assert build_mock.await_count == 2
|
||||
finally:
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extensions_builder_api_deploy_updates_user_extension(
|
||||
tmp_path, settings: Settings, mocker, admin_user
|
||||
):
|
||||
ext_id = f"deploy_{uuid4().hex[:8]}"
|
||||
data = make_extension_data(ext_id)
|
||||
account = await get_account(admin_user.id)
|
||||
assert account is not None
|
||||
|
||||
build_root = tmp_path / "deploy-root" / ext_id
|
||||
build_root.mkdir(parents=True, exist_ok=True)
|
||||
(build_root / "manifest.json").write_text("{}")
|
||||
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
await create_user_extension(
|
||||
UserExtension(user=account.id, extension=ext_id, active=False)
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
|
||||
mocker.AsyncMock(
|
||||
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
|
||||
),
|
||||
)
|
||||
install_mock = mocker.patch(
|
||||
"lnbits.core.views.extensions_builder_api.install_extension",
|
||||
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
|
||||
)
|
||||
activate_mock = mocker.patch(
|
||||
"lnbits.core.views.extensions_builder_api.activate_extension",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path)
|
||||
deployed = await api_deploy_extension(data, account=account)
|
||||
finally:
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert deployed.success is True
|
||||
assert ext_id in deployed.message
|
||||
install_mock.assert_awaited_once()
|
||||
activate_mock.assert_awaited_once()
|
||||
|
||||
user_ext = await get_user_extension(account.id, ext_id)
|
||||
assert user_ext is not None
|
||||
assert user_ext.active is True
|
||||
@@ -0,0 +1,146 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.fiat.base import FiatSubscriptionResponse
|
||||
|
||||
|
||||
class _UnsetSecret:
|
||||
pass
|
||||
|
||||
|
||||
UNSET_SECRET = _UnsetSecret()
|
||||
|
||||
|
||||
class FakeStripeWallet:
|
||||
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
|
||||
self._secret = (
|
||||
"connection-token" if isinstance(secret, _UnsetSecret) else secret
|
||||
)
|
||||
|
||||
async def create_terminal_connection_token(self) -> dict[str, str]:
|
||||
if self._secret is None:
|
||||
return {}
|
||||
return {"secret": self._secret}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fiat_api_test_provider_and_subscription_lifecycle(
|
||||
client: AsyncClient,
|
||||
superuser_token: str,
|
||||
adminkey_headers_from: dict[str, str],
|
||||
from_wallet,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
test_connection = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.test_connection",
|
||||
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
|
||||
)
|
||||
response = await client.put(
|
||||
"/api/v1/fiat/check/stripe",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
test_connection.assert_awaited_once_with("stripe")
|
||||
|
||||
provider = mocker.Mock()
|
||||
provider.create_subscription = mocker.AsyncMock(
|
||||
return_value=FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
subscription_request_id="sub-1",
|
||||
checkout_session_url="https://stripe.example/checkout",
|
||||
)
|
||||
)
|
||||
provider.cancel_subscription = mocker.AsyncMock(
|
||||
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
|
||||
)
|
||||
get_provider = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.get_fiat_provider",
|
||||
mocker.AsyncMock(return_value=provider),
|
||||
)
|
||||
|
||||
mismatch = await client.post(
|
||||
"/api/v1/fiat/stripe/subscription",
|
||||
headers=adminkey_headers_from,
|
||||
json={
|
||||
"subscription_id": "sub-1",
|
||||
"quantity": 2,
|
||||
"payment_options": {"wallet_id": "wrong-wallet"},
|
||||
},
|
||||
)
|
||||
assert mismatch.status_code == 403
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/fiat/stripe/subscription",
|
||||
headers=adminkey_headers_from,
|
||||
json={
|
||||
"subscription_id": "sub-1",
|
||||
"quantity": 2,
|
||||
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
|
||||
provider.create_subscription.assert_awaited_once()
|
||||
assert provider.create_subscription.await_args is not None
|
||||
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
|
||||
|
||||
cancelled = await client.delete(
|
||||
"/api/v1/fiat/stripe/subscription/sub-1",
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
assert cancelled.status_code == 200
|
||||
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
|
||||
assert get_provider.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fiat_api_connection_token_validates_provider_configuration(
|
||||
client: AsyncClient,
|
||||
superuser_token: str,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
headers = {"Authorization": f"Bearer {superuser_token}"}
|
||||
|
||||
not_found = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.get_fiat_provider",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
|
||||
assert missing.status_code == 404
|
||||
assert not_found.await_count == 1
|
||||
|
||||
unsupported_provider = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.get_fiat_provider",
|
||||
mocker.AsyncMock(return_value=object()),
|
||||
)
|
||||
unsupported = await client.post(
|
||||
"/api/v1/fiat/paypal/connection_token", headers=headers
|
||||
)
|
||||
assert unsupported.status_code == 400
|
||||
assert unsupported_provider.await_count == 1
|
||||
|
||||
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
|
||||
bad_wallet = FakeStripeWallet(secret=None)
|
||||
bad_provider = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.get_fiat_provider",
|
||||
mocker.AsyncMock(return_value=bad_wallet),
|
||||
)
|
||||
no_secret = await client.post(
|
||||
"/api/v1/fiat/stripe/connection_token", headers=headers
|
||||
)
|
||||
assert no_secret.status_code == 500
|
||||
assert no_secret.json()["detail"] == "Failed to create connection token"
|
||||
assert bad_provider.await_count == 1
|
||||
|
||||
good_wallet = FakeStripeWallet(secret="tok_live")
|
||||
good_provider = mocker.patch(
|
||||
"lnbits.core.views.fiat_api.get_fiat_provider",
|
||||
mocker.AsyncMock(return_value=good_wallet),
|
||||
)
|
||||
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json() == {"secret": "tok_live"}
|
||||
assert good_provider.await_count == 1
|
||||
@@ -0,0 +1,142 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from lnurl import (
|
||||
LnAddress,
|
||||
LnurlAuthResponse,
|
||||
LnurlErrorResponse,
|
||||
LnurlException,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayResponse,
|
||||
LnurlResponseException,
|
||||
)
|
||||
from lnurl.models import MessageAction
|
||||
from lnurl.types import CallbackUrl, LightningInvoice
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
from lnbits.core.models import Account, CreateInvoice
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.lnurl_api import (
|
||||
api_lnurlscan,
|
||||
api_lnurlscan_post,
|
||||
api_payments_pay_lnurl,
|
||||
api_perform_lnurlauth,
|
||||
)
|
||||
from tests.helpers import make_lnurl_pay_response
|
||||
|
||||
TEST_BOLT11 = (
|
||||
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
|
||||
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
|
||||
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
|
||||
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
|
||||
"6hjxepwp93cq398l3s"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
|
||||
pay_response = make_lnurl_pay_response()
|
||||
mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.lnurl_handle",
|
||||
mocker.AsyncMock(return_value=pay_response),
|
||||
)
|
||||
|
||||
scanned = await api_lnurlscan("lnurl1example")
|
||||
assert isinstance(scanned, LnurlPayResponse)
|
||||
assert scanned.callback == pay_response.callback
|
||||
|
||||
scanned_post = await api_lnurlscan_post(
|
||||
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
|
||||
)
|
||||
assert isinstance(scanned_post, LnurlPayResponse)
|
||||
assert scanned_post.callback == pay_response.callback
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.lnurl_handle",
|
||||
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
|
||||
)
|
||||
with pytest.raises(HTTPException, match="blocked callback"):
|
||||
await api_lnurlscan("lnurl1blocked")
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.lnurl_handle",
|
||||
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
|
||||
)
|
||||
with pytest.raises(HTTPException, match="invalid lnurl"):
|
||||
await api_lnurlscan("lnurl1invalid")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lnurl_api_auth_and_pay_flow(mocker):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
|
||||
pay_response = make_lnurl_pay_response()
|
||||
payment = await create_wallet_invoice(
|
||||
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
|
||||
)
|
||||
|
||||
auth_response = LnurlAuthResponse(
|
||||
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
|
||||
k1="k1-value",
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.lnurlauth",
|
||||
mocker.AsyncMock(return_value=auth_response),
|
||||
)
|
||||
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
|
||||
assert isinstance(authenticated, LnurlAuthResponse)
|
||||
assert authenticated.k1 == "k1-value"
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.lnurlauth",
|
||||
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
|
||||
)
|
||||
with pytest.raises(HTTPException, match="denied"):
|
||||
await api_perform_lnurlauth(auth_response, wallet_info)
|
||||
|
||||
action_response = LnurlPayActionResponse(
|
||||
pr=LightningInvoice(TEST_BOLT11),
|
||||
disposable=False,
|
||||
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
|
||||
)
|
||||
fetch_mock = mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
|
||||
mocker.AsyncMock(return_value=(pay_response, action_response)),
|
||||
)
|
||||
pay_mock = mocker.patch(
|
||||
"lnbits.core.views.lnurl_api.pay_invoice",
|
||||
mocker.AsyncMock(return_value=payment),
|
||||
)
|
||||
|
||||
paid = await api_payments_pay_lnurl(
|
||||
CreateLnurlPayment(
|
||||
res=pay_response, amount=2_000, unit="USD", comment="thanks"
|
||||
),
|
||||
wallet_info,
|
||||
)
|
||||
assert paid.payment_hash == payment.payment_hash
|
||||
fetch_mock.assert_awaited_once()
|
||||
pay_mock.assert_awaited_once()
|
||||
assert pay_mock.await_args is not None
|
||||
assert action_response.successAction is not None
|
||||
assert pay_mock.await_args.kwargs["extra"] == {
|
||||
"stored": True,
|
||||
"success_action": action_response.successAction.json(),
|
||||
"comment": "thanks",
|
||||
"fiat_currency": "USD",
|
||||
"fiat_amount": 2.0,
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
|
||||
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
|
||||
@@ -0,0 +1,366 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.views import node_api
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
Node,
|
||||
NodeChannel,
|
||||
NodeFees,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodePayment,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.wallets.base import Feature
|
||||
|
||||
|
||||
class FakeNode:
|
||||
def __init__(self):
|
||||
self.channel = NodeChannel(
|
||||
id="chan-1",
|
||||
short_id="123x1x0",
|
||||
peer_id="peer-1",
|
||||
name="Peer One",
|
||||
color="#ffffff",
|
||||
state=ChannelState.ACTIVE,
|
||||
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
|
||||
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
|
||||
fee_ppm=10,
|
||||
fee_base_msat=1000,
|
||||
)
|
||||
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
|
||||
self.info = NodeInfoResponse(
|
||||
id="node-id",
|
||||
backend_name="FakeNode",
|
||||
alias="Fake Alias",
|
||||
color="#ffffff",
|
||||
num_peers=1,
|
||||
blockheight=1,
|
||||
channel_stats=ChannelStats(
|
||||
counts={ChannelState.ACTIVE: 1},
|
||||
avg_size=3000,
|
||||
biggest_size=3000,
|
||||
smallest_size=3000,
|
||||
total_capacity=3000,
|
||||
),
|
||||
addresses=["127.0.0.1:9735"],
|
||||
onchain_balance_sat=1,
|
||||
onchain_confirmed_sat=1,
|
||||
fees=NodeFees(total_msat=0),
|
||||
balance_msat=3000,
|
||||
)
|
||||
self.fees_updated: tuple[str, int | None, int | None] | None = None
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
return PublicNodeInfo(**self.info.dict())
|
||||
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
return self.info
|
||||
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
return [self.channel]
|
||||
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
return self.channel if channel_id == self.channel.id else None
|
||||
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
funding_amount: int,
|
||||
push_amount: int | None = None,
|
||||
fee_rate: int | None = None,
|
||||
) -> ChannelPoint:
|
||||
assert peer_id == "peer-1"
|
||||
assert funding_amount == 10_000
|
||||
assert push_amount == 100
|
||||
assert fee_rate == 5
|
||||
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
|
||||
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: str | None = None,
|
||||
point: ChannelPoint | None = None,
|
||||
force: bool = False,
|
||||
) -> list[NodeChannel]:
|
||||
assert short_id == self.channel.short_id
|
||||
assert point is None
|
||||
assert force is True
|
||||
return [self.channel]
|
||||
|
||||
async def set_channel_fee(
|
||||
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
|
||||
) -> None:
|
||||
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
|
||||
|
||||
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
|
||||
return Page(
|
||||
data=[
|
||||
NodePayment(
|
||||
pending=False,
|
||||
amount=1,
|
||||
fee=0,
|
||||
memo="payment",
|
||||
time=1,
|
||||
preimage="11" * 32,
|
||||
payment_hash="22" * 32,
|
||||
)
|
||||
],
|
||||
total=1,
|
||||
)
|
||||
|
||||
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
|
||||
return Page(
|
||||
data=[
|
||||
NodeInvoice(
|
||||
pending=False,
|
||||
amount=1,
|
||||
memo="invoice",
|
||||
bolt11="lnbc1dummy",
|
||||
preimage="11" * 32,
|
||||
payment_hash="33" * 32,
|
||||
)
|
||||
],
|
||||
total=1,
|
||||
)
|
||||
|
||||
async def get_peers(self) -> list[NodePeerInfo]:
|
||||
return [self.peer]
|
||||
|
||||
async def connect_peer(self, uri: str) -> dict[str, str]:
|
||||
return {"uri": uri}
|
||||
|
||||
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
|
||||
return {"peer_id": peer_id}
|
||||
|
||||
async def get_id(self) -> str:
|
||||
return "fake-node-id"
|
||||
|
||||
|
||||
class FakeFundingSource:
|
||||
def __init__(
|
||||
self,
|
||||
features: list[Feature],
|
||||
node_factory: Callable[[Any], Any] | None,
|
||||
):
|
||||
self.features = features
|
||||
self.__node_cls__ = node_factory
|
||||
|
||||
|
||||
class MockHTTPResponse:
|
||||
def __init__(
|
||||
self, json_data: dict[str, Any], status_error: Exception | None = None
|
||||
):
|
||||
self._json_data = json_data
|
||||
self._status_error = status_error
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self._status_error:
|
||||
raise self._status_error
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._json_data
|
||||
|
||||
|
||||
class MockHTTPClient:
|
||||
def __init__(self, response: MockHTTPResponse):
|
||||
self.response = response
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url: str, timeout: int):
|
||||
self.calls.append(url)
|
||||
return self.response
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
|
||||
original_node_ui = settings.lnbits_node_ui
|
||||
original_public = settings.lnbits_public_node_ui
|
||||
try:
|
||||
settings.lnbits_node_ui = True
|
||||
funding_source = FakeFundingSource([], None)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.node_api.get_funding_source",
|
||||
return_value=funding_source,
|
||||
)
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
node_api.require_node()
|
||||
assert excinfo.value.status_code == 501
|
||||
|
||||
node_enabled_source = FakeFundingSource(
|
||||
[Feature.nodemanager], lambda wallet: "fake-node"
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.node_api.get_funding_source",
|
||||
return_value=node_enabled_source,
|
||||
)
|
||||
settings.lnbits_node_ui = False
|
||||
with pytest.raises(HTTPException) as disabled:
|
||||
node_api.require_node()
|
||||
assert disabled.value.status_code == 503
|
||||
|
||||
settings.lnbits_node_ui = True
|
||||
assert node_api.require_node() == "fake-node"
|
||||
|
||||
settings.lnbits_public_node_ui = False
|
||||
with pytest.raises(HTTPException) as public_disabled:
|
||||
node_api.check_public()
|
||||
assert public_disabled.value.status_code == 503
|
||||
finally:
|
||||
settings.lnbits_node_ui = original_node_ui
|
||||
settings.lnbits_public_node_ui = original_public
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_node_api_route_functions_with_fake_node(
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
fake_node = FakeNode()
|
||||
node = cast(Node, fake_node)
|
||||
original_transactions = settings.lnbits_node_ui_transactions
|
||||
settings.lnbits_node_ui_transactions = True
|
||||
rank_response = MockHTTPResponse(
|
||||
{
|
||||
"noderank": {
|
||||
"capacity": 1,
|
||||
"channelcount": 2,
|
||||
"age": 3,
|
||||
"growth": 4,
|
||||
"availability": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.views.node_api.httpx.AsyncClient",
|
||||
return_value=MockHTTPClient(rank_response),
|
||||
)
|
||||
|
||||
try:
|
||||
assert await node_api.api_get_ok() is None
|
||||
|
||||
public_info = await node_api.api_get_public_info(node=node)
|
||||
assert public_info.backend_name == "FakeNode"
|
||||
|
||||
info = await node_api.api_get_info(node=node)
|
||||
assert info is not None
|
||||
assert info.id == "node-id"
|
||||
|
||||
channels = await node_api.api_get_channels(node=node)
|
||||
assert channels is not None
|
||||
assert channels[0].id == "chan-1"
|
||||
|
||||
channel = await node_api.api_get_channel("chan-1", node=node)
|
||||
assert channel is not None
|
||||
assert channel.peer_id == "peer-1"
|
||||
|
||||
created = await node_api.api_create_channel(
|
||||
node=node,
|
||||
peer_id="peer-1",
|
||||
funding_amount=10_000,
|
||||
push_amount=100,
|
||||
fee_rate=5,
|
||||
)
|
||||
assert created.output_index == 0
|
||||
|
||||
deleted = await node_api.api_delete_channel(
|
||||
short_id="123x1x0",
|
||||
funding_txid=None,
|
||||
output_index=None,
|
||||
force=True,
|
||||
node=node,
|
||||
)
|
||||
assert deleted is not None
|
||||
assert deleted[0].id == "chan-1"
|
||||
|
||||
await node_api.api_set_channel_fees(
|
||||
"chan-1",
|
||||
node=node,
|
||||
fee_ppm=42,
|
||||
fee_base_msat=7,
|
||||
)
|
||||
assert fake_node.fees_updated == ("chan-1", 7, 42)
|
||||
|
||||
payments = await node_api.api_get_payments(node=node, filters=Filters())
|
||||
assert payments is not None
|
||||
assert payments.total == 1
|
||||
|
||||
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
|
||||
assert invoices is not None
|
||||
assert invoices.total == 1
|
||||
|
||||
peers = await node_api.api_get_peers(node=node)
|
||||
assert peers[0].id == "peer-1"
|
||||
|
||||
connect = await node_api.api_connect_peer(
|
||||
uri="peer-1@127.0.0.1:9735", node=node
|
||||
)
|
||||
assert connect["uri"] == "peer-1@127.0.0.1:9735"
|
||||
|
||||
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
|
||||
assert disconnect["peer_id"] == "peer-1"
|
||||
|
||||
rank = await node_api.api_get_1ml_stats(node=node)
|
||||
assert rank is not None
|
||||
rank_data = node_api.NodeRank.parse_obj(rank)
|
||||
assert rank_data.channelcount == 2
|
||||
finally:
|
||||
settings.lnbits_node_ui_transactions = original_transactions
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_node_api_transactions_and_rank_errors(
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
fake_node = FakeNode()
|
||||
node = cast(Node, fake_node)
|
||||
original_transactions = settings.lnbits_node_ui_transactions
|
||||
settings.lnbits_node_ui_transactions = False
|
||||
|
||||
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
|
||||
mocker.patch(
|
||||
"lnbits.core.views.node_api.httpx.AsyncClient",
|
||||
return_value=MockHTTPClient(
|
||||
MockHTTPResponse(
|
||||
{},
|
||||
status_error=httpx.HTTPStatusError(
|
||||
"not found", request=request, response=httpx.Response(404)
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(HTTPException) as payments:
|
||||
await node_api.api_get_payments(node=node, filters=Filters())
|
||||
assert payments.value.status_code == 503
|
||||
|
||||
with pytest.raises(HTTPException) as invoices:
|
||||
await node_api.api_get_invoices(node=node, filters=Filters())
|
||||
assert invoices.value.status_code == 503
|
||||
|
||||
with pytest.raises(HTTPException) as rank:
|
||||
await node_api.api_get_1ml_stats(node=node)
|
||||
assert rank.value.status_code == 404
|
||||
assert rank.value.detail == "Node not found on 1ml.com"
|
||||
finally:
|
||||
settings.lnbits_node_ui_transactions = original_transactions
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits.core.crud.payments import create_payment
|
||||
from lnbits.core.models import Account, CreateInvoice, PaymentState
|
||||
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.payment_api import (
|
||||
api_all_payments_paginated,
|
||||
api_payments_cancel,
|
||||
api_payments_counting_stats,
|
||||
api_payments_daily_stats,
|
||||
api_payments_fee_reserve,
|
||||
api_payments_settle,
|
||||
api_payments_wallets_stats,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.wallets.base import InvoiceResponse
|
||||
|
||||
ZERO_AMOUNT_INVOICE = (
|
||||
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
|
||||
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
|
||||
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
|
||||
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
|
||||
"6hjxepwp93cq398l3s"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_payment_api_stats_and_all_paginated(admin_user):
|
||||
first_user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
second_user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
first_wallet = first_user.wallets[0]
|
||||
second_wallet = second_user.wallets[0]
|
||||
|
||||
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
|
||||
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
|
||||
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
|
||||
|
||||
count_stats = await api_payments_counting_stats(
|
||||
count_by="tag",
|
||||
filters=Filters(limit=20),
|
||||
account_id=AccountId(id=first_user.id),
|
||||
)
|
||||
assert any(item.field == "coffee" for item in count_stats)
|
||||
assert all(item.field != "books" for item in count_stats)
|
||||
|
||||
wallet_stats = await api_payments_wallets_stats(
|
||||
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
|
||||
)
|
||||
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
|
||||
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
|
||||
|
||||
daily_stats = await api_payments_daily_stats(
|
||||
account_id=AccountId(id=first_user.id),
|
||||
filters=Filters(limit=20),
|
||||
)
|
||||
assert daily_stats
|
||||
assert daily_stats[0].payments_count >= 1
|
||||
|
||||
regular_page = await api_all_payments_paginated(
|
||||
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
|
||||
)
|
||||
assert regular_page.total >= 2
|
||||
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
|
||||
|
||||
admin_page = await api_all_payments_paginated(
|
||||
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
|
||||
)
|
||||
wallet_ids = {payment.wallet_id for payment in admin_page.data}
|
||||
assert first_wallet.id in wallet_ids
|
||||
assert second_wallet.id in wallet_ids
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
|
||||
invoice = await create_wallet_invoice(
|
||||
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
|
||||
)
|
||||
reserve = await api_payments_fee_reserve(invoice.bolt11)
|
||||
assert json.loads(reserve.body)["fee_reserve"] >= 0
|
||||
|
||||
with pytest.raises(HTTPException, match="Invoice has no amount."):
|
||||
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
|
||||
|
||||
preimage = "11" * 32
|
||||
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
|
||||
await _create_payment(
|
||||
wallet.id,
|
||||
amount_msat=1_000,
|
||||
payment_hash=payment_hash,
|
||||
status=PaymentState.PENDING,
|
||||
)
|
||||
settle_mock = mocker.patch(
|
||||
"lnbits.core.views.payment_api.settle_hold_invoice",
|
||||
mocker.AsyncMock(
|
||||
return_value=InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id="settled",
|
||||
preimage=preimage,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
settled = await api_payments_settle(
|
||||
SettleInvoice(preimage=preimage),
|
||||
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
|
||||
)
|
||||
assert settled.success is True
|
||||
settle_mock.assert_awaited_once()
|
||||
|
||||
cancel_hash = (uuid4().hex * 2)[:64]
|
||||
await _create_payment(
|
||||
wallet.id,
|
||||
amount_msat=2_000,
|
||||
payment_hash=cancel_hash,
|
||||
status=PaymentState.PENDING,
|
||||
)
|
||||
cancel_mock = mocker.patch(
|
||||
"lnbits.core.views.payment_api.cancel_hold_invoice",
|
||||
mocker.AsyncMock(
|
||||
return_value=InvoiceResponse(
|
||||
ok=False,
|
||||
checking_id="cancelled",
|
||||
error_message="cancelled",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
cancelled = await api_payments_cancel(
|
||||
CancelInvoice(payment_hash=cancel_hash),
|
||||
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
|
||||
)
|
||||
assert cancelled.failed is True
|
||||
cancel_mock.assert_awaited_once()
|
||||
|
||||
|
||||
async def _create_payment(
|
||||
wallet_id: str,
|
||||
*,
|
||||
amount_msat: int,
|
||||
status: PaymentState = PaymentState.SUCCESS,
|
||||
payment_hash: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> str:
|
||||
checking_id = f"checking_{uuid4().hex[:8]}"
|
||||
await create_payment(
|
||||
checking_id=checking_id,
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
payment_hash=payment_hash or uuid4().hex,
|
||||
bolt11=f"bolt11_{checking_id}",
|
||||
amount_msat=amount_msat,
|
||||
memo=f"payment_{checking_id}",
|
||||
extra={"tag": tag} if tag else {},
|
||||
),
|
||||
status=status,
|
||||
)
|
||||
return checking_id
|
||||
@@ -0,0 +1,71 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tinyurl_api_create_get_redirect_and_delete(
|
||||
client: AsyncClient,
|
||||
adminkey_headers_from: dict[str, str],
|
||||
inkey_headers_from: dict[str, str],
|
||||
inkey_headers_to: dict[str, str],
|
||||
):
|
||||
created = await client.post(
|
||||
"/api/v1/tinyurl",
|
||||
params={"url": "https://example.com/landing", "endless": "true"},
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
assert created.status_code == HTTPStatus.OK
|
||||
tinyurl = created.json()
|
||||
assert tinyurl["url"] == "https://example.com/landing"
|
||||
assert tinyurl["endless"] is True
|
||||
|
||||
fetched = await client.get(
|
||||
f"/api/v1/tinyurl/{tinyurl['id']}",
|
||||
headers=inkey_headers_from,
|
||||
)
|
||||
assert fetched.status_code == HTTPStatus.OK
|
||||
assert fetched.json()["id"] == tinyurl["id"]
|
||||
|
||||
wrong_wallet = await client.get(
|
||||
f"/api/v1/tinyurl/{tinyurl['id']}",
|
||||
headers=inkey_headers_to,
|
||||
)
|
||||
assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND
|
||||
assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl"
|
||||
|
||||
redirect = await client.get(f"/t/{tinyurl['id']}")
|
||||
assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT
|
||||
assert redirect.headers["location"] == "https://example.com/landing"
|
||||
|
||||
deleted = await client.delete(
|
||||
f"/api/v1/tinyurl/{tinyurl['id']}",
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
assert deleted.status_code == HTTPStatus.OK
|
||||
assert deleted.json()["deleted"] is True
|
||||
|
||||
missing_redirect = await client.get(f"/t/{tinyurl['id']}")
|
||||
assert missing_redirect.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tinyurl_api_reuses_existing_entries_for_same_wallet(
|
||||
client: AsyncClient,
|
||||
adminkey_headers_from: dict[str, str],
|
||||
):
|
||||
first = await client.post(
|
||||
"/api/v1/tinyurl",
|
||||
params={"url": "https://example.com/reused"},
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/v1/tinyurl",
|
||||
params={"url": "https://example.com/reused"},
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
|
||||
assert first.status_code == HTTPStatus.OK
|
||||
assert second.status_code == HTTPStatus.OK
|
||||
assert first.json()["id"] == second.json()["id"]
|
||||
@@ -0,0 +1,102 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets
|
||||
from lnbits.core.models import UpdateBalance
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.user_api import api_users_create_user_wallet
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_api_toggle_admin_and_update_balance(
|
||||
http_client: AsyncClient, superuser_token: str
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
|
||||
promote = await http_client.put(
|
||||
f"/users/api/v1/user/{user.id}/admin",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert promote.status_code == 200
|
||||
assert settings.is_admin_user(user.id) is True
|
||||
|
||||
demote = await http_client.put(
|
||||
f"/users/api/v1/user/{user.id}/admin",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert demote.status_code == 200
|
||||
assert settings.is_admin_user(user.id) is False
|
||||
|
||||
balance = await http_client.put(
|
||||
"/users/api/v1/balance",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
json=UpdateBalance(id=wallet.id, amount=7).dict(),
|
||||
)
|
||||
assert balance.status_code == 200
|
||||
assert balance.json()["success"] is True
|
||||
|
||||
updated_wallet = await get_wallet(wallet.id)
|
||||
assert updated_wallet is not None
|
||||
assert updated_wallet.balance == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_api_get_wallets_and_delete_all_wallets(
|
||||
http_client: AsyncClient, superuser_token: str
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare")
|
||||
|
||||
wallets = await http_client.get(
|
||||
f"/users/api/v1/user/{user.id}/wallet",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert wallets.status_code == 200
|
||||
wallet_ids = {wallet["id"] for wallet in wallets.json()}
|
||||
assert extra_wallet.id in wallet_ids
|
||||
|
||||
deleted = await http_client.delete(
|
||||
f"/users/api/v1/user/{user.id}/wallets",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["success"] is True
|
||||
|
||||
active_wallets = await get_wallets(user.id, deleted=False)
|
||||
assert active_wallets == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_api_create_wallet_validates_currency():
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."):
|
||||
await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID")
|
||||
|
||||
wallet = await api_users_create_user_wallet(
|
||||
user.id, name="eur wallet", currency="EUR"
|
||||
)
|
||||
assert wallet.currency == "EUR"
|
||||
@@ -0,0 +1,190 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallet
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services.users import create_user_account
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_share_invite_reject_accept_and_delete(
|
||||
http_client: AsyncClient,
|
||||
):
|
||||
owner = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"owner_{uuid4().hex[:8]}",
|
||||
email=f"owner_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
invited = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"invited_{uuid4().hex[:8]}",
|
||||
email=f"invited_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
source_wallet = owner.wallets[0]
|
||||
owner_headers = _admin_headers(source_wallet.adminkey)
|
||||
|
||||
invite = await http_client.put(
|
||||
"/api/v1/wallet/share/invite",
|
||||
headers=owner_headers,
|
||||
json={
|
||||
"username": invited.username,
|
||||
"permissions": ["view-payments"],
|
||||
"status": "invite_sent",
|
||||
},
|
||||
)
|
||||
assert invite.status_code == 200
|
||||
share_request = invite.json()
|
||||
assert share_request["request_id"]
|
||||
|
||||
reject = await http_client.delete(
|
||||
f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}"
|
||||
)
|
||||
assert reject.status_code == 200
|
||||
assert reject.json()["success"] is True
|
||||
|
||||
removed_share = await http_client.delete(
|
||||
f"/api/v1/wallet/share/{share_request['request_id']}",
|
||||
headers=owner_headers,
|
||||
)
|
||||
assert removed_share.status_code == 200
|
||||
assert removed_share.json()["success"] is True
|
||||
|
||||
invite = await http_client.put(
|
||||
"/api/v1/wallet/share/invite",
|
||||
headers=owner_headers,
|
||||
json={
|
||||
"username": invited.username,
|
||||
"permissions": ["view-payments", "receive-payments"],
|
||||
"status": "invite_sent",
|
||||
},
|
||||
)
|
||||
assert invite.status_code == 200
|
||||
share_request = invite.json()
|
||||
|
||||
create_shared = await http_client.post(
|
||||
f"/api/v1/wallet?usr={invited.id}",
|
||||
json={
|
||||
"name": "shared",
|
||||
"wallet_type": "lightning-shared",
|
||||
"shared_wallet_id": source_wallet.id,
|
||||
},
|
||||
)
|
||||
assert create_shared.status_code == 200
|
||||
mirror_wallet = create_shared.json()
|
||||
assert mirror_wallet["shared_wallet_id"] == source_wallet.id
|
||||
|
||||
approve = await http_client.put(
|
||||
"/api/v1/wallet/share",
|
||||
headers=owner_headers,
|
||||
json={
|
||||
"username": invited.username,
|
||||
"shared_with_wallet_id": mirror_wallet["id"],
|
||||
"permissions": ["view-payments", "receive-payments"],
|
||||
"status": "approved",
|
||||
},
|
||||
)
|
||||
assert approve.status_code == 200
|
||||
assert approve.json()["status"] == "approved"
|
||||
|
||||
delete_share = await http_client.delete(
|
||||
f"/api/v1/wallet/share/{share_request['request_id']}",
|
||||
headers=owner_headers,
|
||||
)
|
||||
assert delete_share.status_code == 200
|
||||
assert delete_share.json()["success"] is True
|
||||
assert await get_wallet(mirror_wallet["id"]) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_paginated_update_reset_and_store_paylinks(
|
||||
http_client: AsyncClient,
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
extra_wallet = await create_wallet(user_id=user.id, wallet_name="second")
|
||||
first_wallet = user.wallets[0]
|
||||
|
||||
page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10")
|
||||
assert page.status_code == 200
|
||||
assert page.json()["total"] >= 2
|
||||
|
||||
renamed = await http_client.put(
|
||||
"/api/v1/wallet/renamed-wallet",
|
||||
headers=_admin_headers(first_wallet.adminkey),
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["name"] == "renamed-wallet"
|
||||
|
||||
original_admin_key = extra_wallet.adminkey
|
||||
reset = await http_client.put(
|
||||
f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}"
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["adminkey"] != original_admin_key
|
||||
|
||||
stored = await http_client.put(
|
||||
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
|
||||
headers=_admin_headers(reset.json()["adminkey"]),
|
||||
json={
|
||||
"links": [
|
||||
{
|
||||
"lnurl": "alice@example.com",
|
||||
"label": "Alice",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert stored.status_code == 200
|
||||
assert stored.json()[0]["lnurl"] == "alice@example.com"
|
||||
|
||||
forbidden = await http_client.put(
|
||||
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
|
||||
headers=_admin_headers(first_wallet.adminkey),
|
||||
json={"links": []},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
updated = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=_admin_headers(first_wallet.adminkey),
|
||||
json={"icon": "bolt", "color": "amber", "pinned": True},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["extra"]["icon"] == "bolt"
|
||||
assert updated.json()["extra"]["color"] == "amber"
|
||||
assert updated.json()["extra"]["pinned"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
|
||||
response = await http_client.post(
|
||||
f"/api/v1/wallet?usr={user.id}",
|
||||
json={"wallet_type": "lightning-shared"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.json()["detail"] == "Shared wallet ID is required for shared wallets."
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers(adminkey: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": adminkey, "Content-type": "application/json"}
|
||||
@@ -0,0 +1,31 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
|
||||
def test_websocket_api_connects_and_updates(test_client):
|
||||
with test_client.websocket_connect("/api/v1/ws/demo-item") as websocket:
|
||||
response = test_client.post("/api/v1/ws/demo-item", params={"data": "hello"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"sent": True, "data": "hello"}
|
||||
assert websocket.receive_text() == "hello"
|
||||
|
||||
response = test_client.get("/api/v1/ws/demo-item/world")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"sent": True, "data": "world"}
|
||||
assert websocket.receive_text() == "world"
|
||||
|
||||
|
||||
def test_websocket_api_reports_send_failures(test_client, mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"lnbits.core.views.websocket_api.websocket_manager.send",
|
||||
AsyncMock(side_effect=RuntimeError("boom")),
|
||||
)
|
||||
|
||||
post_response = test_client.post("/api/v1/ws/demo-item", params={"data": "oops"})
|
||||
assert post_response.status_code == 200
|
||||
assert post_response.json() == {"sent": False, "data": "oops"}
|
||||
|
||||
get_response = test_client.get("/api/v1/ws/demo-item/oops")
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json() == {"sent": False, "data": "oops"}
|
||||