Compare commits

..
Author SHA1 Message Date
dni ⚡ 2329770acd chore: update to version v1.5.2 (#3891) 2026-03-23 17:39:49 +01:00
94 changed files with 3343 additions and 5125 deletions
+1 -45
View File
@@ -94,7 +94,7 @@ AUTH_SECRET_KEY=""
###################################### ######################################
AUTH_TOKEN_EXPIRE_MINUTES=525600 AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth # Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password" AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth # Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1" # OAUTHLIB_INSECURE_TRANSPORT="1"
@@ -271,50 +271,6 @@ KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG="" KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON="" 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
###################################### ######################################
-29
View File
@@ -1,29 +0,0 @@
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
+7 -10
View File
@@ -8,6 +8,7 @@ on:
jobs: jobs:
lint: lint:
uses: ./.github/workflows/lint.yml uses: ./.github/workflows/lint.yml
@@ -15,7 +16,7 @@ jobs:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.12"] python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
@@ -29,7 +30,7 @@ jobs:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.12"] python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
@@ -43,7 +44,7 @@ jobs:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.12"] python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
@@ -57,7 +58,7 @@ jobs:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.12"] python-version: ["3.10", "3.11", "3.12"]
uses: ./.github/workflows/migration.yml uses: ./.github/workflows/migration.yml
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -73,7 +74,7 @@ jobs:
uses: ./.github/workflows/regtest.yml uses: ./.github/workflows/regtest.yml
strategy: strategy:
matrix: matrix:
python-version: ["3.12"] python-version: ["3.10"]
backend-wallet-class: backend-wallet-class:
- BoltzWallet - BoltzWallet
- LndRestWallet - LndRestWallet
@@ -93,11 +94,7 @@ jobs:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
matrix: matrix:
python-version: ["3.12"] python-version: ["3.10"]
uses: ./.github/workflows/jmeter.yml uses: ./.github/workflows/jmeter.yml
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
-1
View File
@@ -22,7 +22,6 @@ jobs:
- name: run LNbits - name: run LNbits
env: env:
LNBITS_ADMIN_UI: true LNBITS_ADMIN_UI: true
AUTH_HTTPS_ONLY: false
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw" LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: | run: |
+23
View File
@@ -6,30 +6,53 @@ jobs:
black: black:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
with: with:
make: checkblack make: checkblack
python-version: ${{ matrix.python-version }}
ruff: ruff:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
with: with:
make: checkruff make: checkruff
python-version: ${{ matrix.python-version }}
mypy: mypy:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
with: with:
make: mypy make: mypy
python-version: ${{ matrix.python-version }}
pyright: pyright:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
with: with:
make: pyright make: pyright
python-version: ${{ matrix.python-version }}
npm: true npm: true
prettier: prettier:
uses: ./.github/workflows/make.yml uses: ./.github/workflows/make.yml
with: with:
make: checkprettier make: checkprettier
npm: true npm: true
bundle:
uses: ./.github/workflows/make.yml
with:
make: checkbundle
npm: true
poetry: poetry:
uses: ./.github/workflows/poetry.yml uses: ./.github/workflows/poetry.yml
+2 -2
View File
@@ -14,7 +14,7 @@ on:
python-version: python-version:
description: "python version" description: "python version"
type: string type: string
default: "3.12" default: "3.10"
jobs: jobs:
make: make:
@@ -22,7 +22,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os-version: ["ubuntu-24.04"] os-version: ["ubuntu-24.04"]
node-version: ["24.x"] node-version: ["18.x"]
runs-on: ${{ matrix.os-version }} runs-on: ${{ matrix.os-version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -1
View File
@@ -8,7 +8,7 @@ on:
required: true required: true
type: string type: string
python-version: python-version:
default: "3.12" default: "3.10"
type: string type: string
os-version: os-version:
default: "ubuntu-24.04" default: "ubuntu-24.04"
+1 -1
View File
@@ -8,7 +8,7 @@ on:
required: true required: true
type: string type: string
python-version: python-version:
default: "3.12" default: "3.10"
type: string type: string
os-version: os-version:
default: "ubuntu-24.04" default: "ubuntu-24.04"
+2 -2
View File
@@ -14,11 +14,11 @@ repos:
- id: mixed-line-ending - id: mixed-line-ending
- id: check-case-conflict - id: check-case-conflict
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 26.3.1 rev: 25.1.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10 rev: v0.12.10
hooks: hooks:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
-139
View File
@@ -1,139 +0,0 @@
# 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
+1 -6
View File
@@ -468,12 +468,7 @@ def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data) create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages) create_permanent_task(wait_notification_messages)
create_permanent_task( create_permanent_task(run_interval(30 * 60, check_pending_payments))
run_interval(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
)
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
+1 -3
View File
@@ -711,9 +711,7 @@ async def _call_install_extension(
user_id = user_id or get_super_user() user_id = user_id or get_super_user()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{url}/api/v1/extension?usr={user_id}", f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40
json=data.model_dump(),
timeout=40,
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
+8 -4
View File
@@ -149,12 +149,14 @@ async def get_payments_paginated( # noqa: C901
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')" f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
) )
elif complete: elif complete:
clause.append(f""" clause.append(
f"""
( (
status = '{PaymentState.SUCCESS}' status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}') OR (amount < 0 AND status = '{PaymentState.PENDING}')
) )
""") """
)
elif pending: elif pending:
clause.append(f"status = '{PaymentState.PENDING}'") clause.append(f"status = '{PaymentState.PENDING}'")
elif failed: elif failed:
@@ -344,12 +346,14 @@ async def get_payments_history(
"wallet_id": wallet_id, "wallet_id": wallet_id,
} }
# count outgoing payments if they are still pending # count outgoing payments if they are still pending
where = [f""" where = [
f"""
wallet_id = :wallet_id AND ( wallet_id = :wallet_id AND (
status = '{PaymentState.SUCCESS}' status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}') OR (amount < 0 AND status = '{PaymentState.PENDING}')
) )
"""] """
]
clause = filters.where(where) clause = filters.where(where)
transactions: list[dict] = await db.fetchall( transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection: # This query is safe from SQL injection:
+1 -1
View File
@@ -44,7 +44,7 @@ async def update_admin_settings(
data: EditableSettings, tag: str | None = "core" data: EditableSettings, tag: str | None = "core"
) -> None: ) -> None:
editable_settings = await get_settings_by_tag("core") or {} editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.model_dump(exclude_unset=True)) editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items(): for key, value in editable_settings.items():
try: try:
await set_settings_field(key, value, tag) await set_settings_field(key, value, tag)
+11 -21
View File
@@ -4,12 +4,7 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import ( from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
clear_wallet_cache,
create_wallet,
get_standalone_wallet,
get_wallets,
)
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
@@ -57,22 +52,17 @@ async def get_accounts(
) -> Page[AccountOverview]: ) -> Page[AccountOverview]:
where_clauses = [] where_clauses = []
values: dict[str, Any] = {} values: dict[str, Any] = {}
filters = filters or Filters()
wallet_filter = filters.get_filter_by_field("wallet_id") # Make wallet filter explicit
wallet_filter = (
if wallet_filter and wallet_filter.values: next((f for f in filters.filters if f.field == "wallet_id"), None)
wallet_id_value = next(iter(wallet_filter.values.values()), None) if filters
wallet = ( else None
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn) )
if wallet_id_value if filters and wallet_filter and wallet_filter.values:
else None where_clauses.append("wallets.id = :wallet_id")
) values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
if not wallet: filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
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( return await (conn or db).fetch_page(
""" """
+124 -62
View File
@@ -10,26 +10,31 @@ from lnbits.db import Connection
async def m000_create_migrations_table(db: Connection): async def m000_create_migrations_table(db: Connection):
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions ( CREATE TABLE IF NOT EXISTS dbversions (
db TEXT PRIMARY KEY, db TEXT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
) )
""") """
)
async def m001_initial(db: Connection): async def m001_initial(db: Connection):
""" """
Initial LNbits tables. Initial LNbits tables.
""" """
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT, email TEXT,
pass TEXT pass TEXT
); );
""") """
await db.execute(""" )
await db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions ( CREATE TABLE IF NOT EXISTS extensions (
"user" TEXT NOT NULL, "user" TEXT NOT NULL,
extension TEXT NOT NULL, extension TEXT NOT NULL,
@@ -37,8 +42,10 @@ async def m001_initial(db: Connection):
UNIQUE ("user", extension) UNIQUE ("user", extension)
); );
""") """
await db.execute(""" )
await db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -46,8 +53,10 @@ async def m001_initial(db: Connection):
adminkey TEXT NOT NULL, adminkey TEXT NOT NULL,
inkey TEXT inkey TEXT
); );
""") """
await db.execute(f""" )
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS apipayments ( CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL, payhash TEXT NOT NULL,
amount {db.big_int} NOT NULL, amount {db.big_int} NOT NULL,
@@ -58,9 +67,11 @@ async def m001_initial(db: Connection):
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE (wallet, payhash) UNIQUE (wallet, payhash)
); );
""") """
)
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM ( SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming SELECT wallet, SUM(amount) AS s -- incoming
@@ -74,7 +85,8 @@ async def m001_initial(db: Connection):
GROUP BY wallet GROUP BY wallet
)x )x
GROUP BY wallet; GROUP BY wallet;
""") """
)
async def m002_add_fields_to_apipayments(db: Connection): async def m002_add_fields_to_apipayments(db: Connection):
@@ -137,7 +149,8 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
""" """
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM ( SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming SELECT wallet, SUM(amount) AS s -- incoming
@@ -151,7 +164,8 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
GROUP BY wallet GROUP BY wallet
)x )x
GROUP BY wallet; GROUP BY wallet;
""") """
)
async def m005_balance_check_balance_notify(db: Connection): async def m005_balance_check_balance_notify(db: Connection):
@@ -160,7 +174,8 @@ async def m005_balance_check_balance_notify(db: Connection):
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets. 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 ( CREATE TABLE IF NOT EXISTS balance_check (
wallet TEXT NOT NULL REFERENCES wallets (id), wallet TEXT NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL, service TEXT NOT NULL,
@@ -168,16 +183,19 @@ async def m005_balance_check_balance_notify(db: Connection):
UNIQUE(wallet, service) UNIQUE(wallet, service)
); );
""") """
)
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS balance_notify ( CREATE TABLE IF NOT EXISTS balance_notify (
wallet TEXT NOT NULL REFERENCES wallets (id), wallet TEXT NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL, url TEXT NOT NULL,
UNIQUE(wallet, url) UNIQUE(wallet, url)
); );
""") """
)
async def m006_add_invoice_expiry_to_apipayments(db: Connection): async def m006_add_invoice_expiry_to_apipayments(db: Connection):
@@ -244,16 +262,19 @@ async def m007_set_invoice_expiries(db: Connection):
async def m008_create_admin_settings_table(db: Connection): async def m008_create_admin_settings_table(db: Connection):
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
super_user TEXT, super_user TEXT,
editable_settings TEXT NOT NULL DEFAULT '{}' editable_settings TEXT NOT NULL DEFAULT '{}'
); );
""") """
)
async def m009_create_tinyurl_table(db: Connection): async def m009_create_tinyurl_table(db: Connection):
await db.execute(f""" await db.execute(
f"""
CREATE TABLE IF NOT EXISTS tiny_url ( CREATE TABLE IF NOT EXISTS tiny_url (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
url TEXT, url TEXT,
@@ -261,11 +282,13 @@ async def m009_create_tinyurl_table(db: Connection):
wallet TEXT, wallet TEXT,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""") """
)
async def m010_create_installed_extensions_table(db: Connection): async def m010_create_installed_extensions_table(db: Connection):
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS installed_extensions ( CREATE TABLE IF NOT EXISTS installed_extensions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
version TEXT NOT NULL, version TEXT NOT NULL,
@@ -276,7 +299,8 @@ async def m010_create_installed_extensions_table(db: Connection):
active BOOLEAN DEFAULT false, active BOOLEAN DEFAULT false,
meta TEXT NOT NULL DEFAULT '{}' meta TEXT NOT NULL DEFAULT '{}'
); );
""") """
)
async def m011_optimize_balances_view(db: Connection): async def m011_optimize_balances_view(db: Connection):
@@ -285,19 +309,23 @@ async def m011_optimize_balances_view(db: Connection):
over the payments table instead of 2. over the payments table instead of 2.
""" """
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT wallet, SUM(amount - abs(fee)) AS balance SELECT wallet, SUM(amount - abs(fee)) AS balance
FROM apipayments FROM apipayments
WHERE (pending = false AND amount > 0) OR amount < 0 WHERE (pending = false AND amount > 0) OR amount < 0
GROUP BY wallet GROUP BY wallet
""") """
)
async def m012_add_currency_to_wallet(db: Connection): async def m012_add_currency_to_wallet(db: Connection):
await db.execute(""" await db.execute(
"""
ALTER TABLE wallets ADD COLUMN currency TEXT ALTER TABLE wallets ADD COLUMN currency TEXT
""") """
)
async def m013_add_deleted_to_wallets(db: Connection): async def m013_add_deleted_to_wallets(db: Connection):
@@ -317,13 +345,15 @@ async def m014_set_deleted_wallets(db: Connection):
Sets deleted column to wallets. Sets deleted column to wallets.
""" """
try: try:
result = await db.execute(""" result = await db.execute(
"""
SELECT * SELECT *
FROM wallets FROM wallets
WHERE user LIKE 'del:%' WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%' AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%' AND inkey LIKE 'del:%'
""") """
)
rows = result.mappings().all() rows = result.mappings().all()
for row in rows: for row in rows:
@@ -356,7 +386,8 @@ async def m014_set_deleted_wallets(db: Connection):
async def m015_create_push_notification_subscriptions_table(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 ( CREATE TABLE IF NOT EXISTS webpush_subscriptions (
endpoint TEXT NOT NULL, endpoint TEXT NOT NULL,
"user" TEXT NOT NULL, "user" TEXT NOT NULL,
@@ -365,7 +396,8 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
PRIMARY KEY (endpoint, "user") PRIMARY KEY (endpoint, "user")
); );
""") """
)
async def m016_add_username_column_to_accounts(db: Connection): async def m016_add_username_column_to_accounts(db: Connection):
@@ -452,7 +484,8 @@ async def m018_balances_view_exclude_deleted(db: Connection):
Make deleted wallets not show up in the balances view. Make deleted wallets not show up in the balances view.
""" """
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT apipayments.wallet, SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -462,7 +495,8 @@ async def m018_balances_view_exclude_deleted(db: Connection):
AND ((apipayments.pending = false AND apipayments.amount > 0) AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0) OR apipayments.amount < 0)
GROUP BY wallet GROUP BY wallet
""") """
)
async def m019_balances_view_based_on_wallets(db: Connection): async def m019_balances_view_based_on_wallets(db: Connection):
@@ -471,7 +505,8 @@ async def m019_balances_view_based_on_wallets(db: Connection):
Important for querying whole lnbits balances. Important for querying whole lnbits balances.
""" """
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT apipayments.wallet, SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -481,7 +516,8 @@ async def m019_balances_view_based_on_wallets(db: Connection):
AND ((apipayments.pending = false AND apipayments.amount > 0) AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0) OR apipayments.amount < 0)
GROUP BY apipayments.wallet GROUP BY apipayments.wallet
""") """
)
async def m020_add_column_column_to_user_extensions(db: Connection): async def m020_add_column_column_to_user_extensions(db: Connection):
@@ -500,7 +536,8 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending") await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT apipayments.wallet, SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -512,7 +549,8 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0) OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
) )
GROUP BY apipayments.wallet GROUP BY apipayments.wallet
""") """
)
async def m022_add_pubkey_to_accounts(db: Connection): async def m022_add_pubkey_to_accounts(db: Connection):
@@ -543,7 +581,8 @@ async def m024_drop_pending(db: Connection):
async def m025_refresh_view(db: Connection): async def m025_refresh_view(db: Connection):
await db.execute("DROP VIEW balances") await db.execute("DROP VIEW balances")
await db.execute(""" await db.execute(
"""
CREATE VIEW balances AS CREATE VIEW balances AS
SELECT apipayments.wallet_id, SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -555,7 +594,8 @@ async def m025_refresh_view(db: Connection):
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0) OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
) )
GROUP BY apipayments.wallet_id GROUP BY apipayments.wallet_id
""") """
)
async def m026_update_payment_table(db: Connection): async def m026_update_payment_table(db: Connection):
@@ -618,7 +658,8 @@ async def m027_update_apipayments_data(db: Connection):
async def m028_update_settings(db: Connection): async def m028_update_settings(db: Connection):
await db.execute(""" await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings ( CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
value TEXT, value TEXT,
@@ -626,7 +667,8 @@ async def m028_update_settings(db: Connection):
UNIQUE (id, tag) UNIQUE (id, tag)
); );
""") """
)
async def _insert_key_value(id_: str, value: Any): async def _insert_key_value(id_: str, value: Any):
await db.execute( await db.execute(
@@ -649,7 +691,8 @@ async def m028_update_settings(db: Connection):
async def m029_create_audit_table(db: Connection): async def m029_create_audit_table(db: Connection):
await db.execute(f""" await db.execute(
f"""
CREATE TABLE IF NOT EXISTS audit ( CREATE TABLE IF NOT EXISTS audit (
component TEXT, component TEXT,
ip_address TEXT, ip_address TEXT,
@@ -663,13 +706,16 @@ async def m029_create_audit_table(db: Connection):
delete_at TIMESTAMP, delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""") """
)
async def m030_add_user_api_tokens_column(db: Connection): 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 ALTER TABLE accounts ADD COLUMN access_control_list TEXT
""") """
)
async def m031_add_color_and_icon_to_wallets(db: Connection): async def m031_add_color_and_icon_to_wallets(db: Connection):
@@ -692,25 +738,32 @@ async def m033_update_payment_table(db: Connection):
async def m034_add_stored_paylinks_to_wallet(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 ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
""") """
)
async def m035_add_wallet_type_column(db: Connection): 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' ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
""") """
)
async def m036_add_shared_wallet_column(db: Connection): async def m036_add_shared_wallet_column(db: Connection):
await db.execute(""" await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
""") """
)
async def m037_create_assets_table(db: Connection): async def m037_create_assets_table(db: Connection):
await db.execute(f""" await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets ( CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -723,13 +776,16 @@ async def m037_create_assets_table(db: Connection):
data {db.blob} NOT NULL, data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""") """
)
async def m038_add_labels_for_payments(db: Connection): async def m038_add_labels_for_payments(db: Connection):
await db.execute(""" await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT ALTER TABLE apipayments ADD COLUMN labels TEXT
""") """
)
async def m039_index_payments(db: Connection): async def m039_index_payments(db: Connection):
@@ -748,9 +804,11 @@ async def m039_index_payments(db: Connection):
] ]
for index in indexes: for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...") 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}); CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
""") """
)
async def m040_index_wallets(db: Connection): async def m040_index_wallets(db: Connection):
@@ -767,9 +825,11 @@ async def m040_index_wallets(db: Connection):
for index in indexes: for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...") 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}"); CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
""") """
)
async def m042_index_accounts(db: Connection): async def m042_index_accounts(db: Connection):
@@ -783,9 +843,11 @@ async def m042_index_accounts(db: Connection):
for index in indexes: for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...") 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}"); CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
""") """
)
async def m043_add_ui_customization_to_accounts(db: Connection): async def m043_add_ui_customization_to_accounts(db: Connection):
+2 -2
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -22,7 +21,8 @@ class AuditEntry(BaseModel):
delete_at: datetime | None = None delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365 retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days) self.delete_at = self.created_at + timedelta(days=retention_days)
+6 -6
View File
@@ -96,7 +96,7 @@ class ExtensionConfig(BaseModel):
) )
error_msg = "Cannot fetch GitHub extension config" error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg) config = await github_api_get(config_url, error_msg)
return ExtensionConfig.model_validate(config) return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
@@ -304,7 +304,7 @@ class ExtensionRelease(BaseModel):
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases" releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases" error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg) releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.model_validate(r) for r in releases] return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod @classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None: async def fetch_release_details(cls, details_link: str) -> dict | None:
@@ -757,7 +757,7 @@ class InstallableExtension(BaseModel):
repo_url = f"https://api.github.com/repos/{org}/{repository}" repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo" error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg) repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.model_validate(repo) github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = ( lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest" f"https://api.github.com/repos/{org}/{repository}/releases/latest"
@@ -771,15 +771,15 @@ class InstallableExtension(BaseModel):
return ( return (
github_repo, github_repo,
GitHubRepoRelease.model_validate(latest_release), GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.model_validate(config), ExtensionConfig.parse_obj(config),
) )
@classmethod @classmethod
async def fetch_manifest(cls, url) -> Manifest: async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest" error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg) manifest = await github_api_get(url, error_msg)
return Manifest.model_validate(manifest) return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel): class CreateExtension(BaseModel):
+21 -31
View File
@@ -6,7 +6,7 @@ import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, field_validator from pydantic import BaseModel, validator
from lnbits.helpers import ( from lnbits.helpers import (
camel_to_snake, camel_to_snake,
@@ -141,8 +141,7 @@ class DataField(BaseModel):
else: else:
return f"{self.name} {index}" return f"{self.name} {index}"
@field_validator("name") @validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Field name is required.") raise ValueError("Field name is required.")
@@ -150,8 +149,7 @@ class DataField(BaseModel):
raise ValueError(f"Field Name must be snake_case. Found: {v}") raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v return v
@field_validator("type") @validator("type")
@classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Owner Data type is required") raise ValueError("Owner Data type is required")
@@ -173,8 +171,7 @@ class DataField(BaseModel):
) )
return v return v
@field_validator("label") @validator("label")
@classmethod
def validate_label(cls, v: str | None) -> str | None: def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -182,8 +179,7 @@ class DataField(BaseModel):
) )
return v return v
@field_validator("hint") @validator("hint")
@classmethod
def validate_hint(cls, v: str | None) -> str | None: def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}') raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
@@ -195,7 +191,8 @@ class DataFields(BaseModel):
editable: bool = True editable: bool = True
fields: list[DataField] = [] fields: list[DataField] = []
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
self.normalize() self.normalize()
def normalize(self) -> None: def normalize(self) -> None:
@@ -213,8 +210,7 @@ class DataFields(BaseModel):
return field return field
return None return None
@field_validator("name") @validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Data fields name is required") raise ValueError("Data fields name is required")
@@ -227,13 +223,12 @@ class SettingsFields(DataFields):
enabled: bool = False enabled: bool = False
type: str = "user" type: str = "user"
@field_validator("type") @validator("type")
@classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Settings type is required") raise ValueError("Settings type is required")
if v not in ["user", "admin"]: if v not in ["user", "admin"]:
raise ValueError(f"Field Type must be one of: user, admin. Found: {v}") raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
return v return v
@@ -270,7 +265,8 @@ class PreviewAction(BaseModel):
is_client_data_preview: bool = False is_client_data_preview: bool = False
is_public_page_preview: bool = False is_public_page_preview: bool = False
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
if not self.is_preview_mode: if not self.is_preview_mode:
self.is_settings_preview = False self.is_settings_preview = False
self.is_owner_data_preview = False self.is_owner_data_preview = False
@@ -290,7 +286,8 @@ class ExtensionData(BaseModel):
public_page: PublicPageFields public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction() preview_action: PreviewAction = PreviewAction()
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
self.validate_data() self.validate_data()
self.normalize() self.normalize()
@@ -437,8 +434,7 @@ class ExtensionData(BaseModel):
f" Received: {paid_flag_field.type}." f" Received: {paid_flag_field.type}."
) )
@field_validator("id") @validator("id")
@classmethod
def validate_id(cls, v: str) -> str: def validate_id(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Extension ID is required") raise ValueError("Extension ID is required")
@@ -446,15 +442,13 @@ class ExtensionData(BaseModel):
raise ValueError(f"Extension Id must be snake_case. Found: {v}") raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v return v
@field_validator("name") @validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Extension name is required") raise ValueError("Extension name is required")
return v return v
@field_validator("stub_version") @validator("stub_version")
@classmethod
def validate_stub_version(cls, v: str | None) -> str | None: def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -462,8 +456,7 @@ class ExtensionData(BaseModel):
) )
return v return v
@field_validator("short_description") @validator("short_description")
@classmethod
def validate_short_description(cls, v: str | None) -> str | None: def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -471,8 +464,7 @@ class ExtensionData(BaseModel):
) )
return v return v
@field_validator("description") @validator("description")
@classmethod
def validate_description(cls, v: str | None) -> str | None: def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -480,15 +472,13 @@ class ExtensionData(BaseModel):
) )
return v return v
@field_validator("owner_data") @validator("owner_data")
@classmethod
def validate_owner_data(cls, v: DataFields) -> DataFields: def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0: if len(v.fields) == 0:
raise ValueError("At least one owner data field is required") raise ValueError("At least one owner data field is required")
return v return v
@field_validator("client_data") @validator("client_data")
@classmethod
def validate_client_data(cls, v: DataFields) -> DataFields: def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0: if len(v.fields) == 0:
raise ValueError("At least one client data field is required") raise ValueError("At least one client data field is required")
+2 -2
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel): class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None res: LnurlPayResponse | None = None
lnurl: Lnurl | None = None lnurl: Lnurl | LnAddress | None = None
amount: int amount: int
comment: str | None = None comment: str | None = None
unit: str | None = None unit: str | None = None
@@ -14,7 +14,7 @@ class CreateLnurlPayment(BaseModel):
class CreateLnurlWithdraw(BaseModel): class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl | LnAddress lnurl_w: Lnurl
class LnurlScan(BaseModel): class LnurlScan(BaseModel):
+9 -14
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Literal from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse from lnurl import LnurlWithdrawResponse
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
@@ -62,10 +62,7 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
payment_request: str | None = Field( payment_request: str | None = Field(default=None, no_database=True)
default=None,
json_schema_extra={"no_database": True},
)
fiat_provider: str | None = None fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
@@ -81,7 +78,8 @@ class Payment(BaseModel):
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
if "fiat_payment_request" in self.extra: if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"] self.payment_request = self.extra["fiat_payment_request"]
else: else:
@@ -252,14 +250,13 @@ class CreateInvoice(BaseModel):
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
@field_validator("payment_hash") @validator("payment_hash")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
if v: if v:
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
@field_validator("unit") @validator("unit")
@classmethod @classmethod
def unit_is_from_allowed_currencies(cls, v): def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies(): if v != "sat" and v not in allowed_currencies():
@@ -282,8 +279,7 @@ class SettleInvoice(BaseModel):
max_length=64, max_length=64,
) )
@field_validator("preimage") @validator("preimage")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
@@ -297,8 +293,7 @@ class CancelInvoice(BaseModel):
max_length=64, max_length=64,
) )
@field_validator("payment_hash") @validator("payment_hash")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
-1
View File
@@ -1 +0,0 @@
"""SSO authentication providers for LNbits"""
-36
View File
@@ -1,36 +0,0 @@
"""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
+9 -16
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from uuid import UUID from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw from bcrypt import checkpw, gensalt, hashpw
@@ -39,9 +38,11 @@ class WalletInviteRequest(BaseModel):
class UserLabel(BaseModel): class UserLabel(BaseModel):
name: str = Field(pattern=r"([A-Za-z0-9 ._-]{1,100}$)") name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250) description: str | None = Field(default=None, max_length=250)
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$") color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
@@ -192,20 +193,12 @@ class Account(AccountId):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field( is_super_user: bool = Field(default=False, no_database=True)
default=False, is_admin: bool = Field(default=False, no_database=True)
json_schema_extra={"no_database": True}, fiat_providers: list[str] = Field(default=[], no_database=True)
)
is_admin: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
fiat_providers: list[str] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id) self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
+5 -8
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -127,16 +126,14 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None currency: str | None = None
balance_msat: int = Field(default=0, json_schema_extra={"no_database": True}) balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = Field(default_factory=StoredPayLinks) stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet # What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field( share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
default=[],
json_schema_extra={"no_database": True},
)
def model_post_init(self, __context: Any) -> None: def __init__(self, **data):
super().__init__(**data)
self._validate_data() self._validate_data()
def mirror_shared_wallet( def mirror_shared_wallet(
+3 -3
View File
@@ -97,7 +97,7 @@ def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path)
def _export_extension_data_json(data: ExtensionData, build_dir: Path): def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump( json.dump(
data.model_dump(), data.dict(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"), open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4, indent=4,
) )
@@ -133,7 +133,7 @@ async def _get_extension_stub_release(
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).") logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f: with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.model_dump(), indent=4)) f.write(json.dumps(release.dict(), indent=4))
return release return release
@@ -290,7 +290,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
{ {
"extension_builder_stub_public_client_inputs": public_client_inputs, "extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action, "preview": data.preview_action,
**data.public_page.action_fields.model_dump(), **data.public_page.action_fields.dict(),
"cancel_comment": remove_line_marker, "cancel_comment": remove_line_marker,
}, },
) )
+1 -2
View File
@@ -45,11 +45,10 @@ def dict_to_settings(sets_dict: dict) -> UpdateSettings:
def update_cached_settings(sets_dict: dict): def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict) editable_settings = dict_to_settings(sets_dict)
settings_keys = settings.model_dump().keys()
for key in sets_dict.keys(): for key in sets_dict.keys():
if key in readonly_variables: if key in readonly_variables:
continue continue
if key not in settings_keys: if key not in settings.dict().keys():
continue continue
try: try:
value = getattr(editable_settings, key) value = getattr(editable_settings, key)
+3 -3
View File
@@ -163,7 +163,7 @@ async def check_admin_settings():
# .env super_user overwrites DB super_user # .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user) settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.model_dump()) update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file # saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file: with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
@@ -192,8 +192,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
await create_account(account) await create_account(account)
await create_wallet(user_id=account.id) await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.model_dump()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.model_dump()) return await create_admin_settings(account.id, editable_settings.dict())
async def check_register_activation_settings(data: RegisterUser): async def check_register_activation_settings(data: RegisterUser):
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}
+2 -2
View File
@@ -87,7 +87,7 @@ async def api_update_settings(
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(account.is_super_user)
if not admin_settings: if not admin_settings:
raise ValueError("Updated admin settings not found.") raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.model_dump()) update_cached_settings(admin_settings.dict())
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
return {"status": "Success"} return {"status": "Success"}
@@ -99,7 +99,7 @@ async def api_update_settings(
async def api_update_settings_partial( async def api_update_settings_partial(
data: dict, account: Account = Depends(check_admin) data: dict, account: Account = Depends(check_admin)
): ):
updatable_settings = dict_to_settings({**settings.model_dump(), **data}) updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, account) return await api_update_settings(updatable_settings, account)
+14 -57
View File
@@ -154,20 +154,9 @@ async def impersonate_user(
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response.set_cookie( response.set_cookie(
"admin_access_token", "admin_access_token", cookie_access_token, httponly=True, max_age=max_age
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 return response
@@ -188,12 +177,7 @@ async def stop_impersonate_user(
) )
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response.set_cookie( response.set_cookie(
"cookie_access_token", "cookie_access_token", admin_access_token, httponly=True, max_age=max_age
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("admin_access_token")
response.delete_cookie("is_access_token_expired") response.delete_cookie("is_access_token_expired")
@@ -576,7 +560,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True) id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
) )
await create_user_account(account) await create_user_account(account)
return _auth_redirect_response(redirect_path, account.id, email) return _auth_redirect_response(redirect_path, email)
def _auth_success_response( def _auth_success_response(
@@ -587,24 +571,13 @@ def _auth_success_response(
payload = AccessTokenPayload( payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time()) sub=username or "", usr=user_id, email=email, auth_time=int(time())
) )
access_token = create_access_token(data=payload.model_dump()) access_token = create_access_token(data=payload.dict())
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response = JSONResponse({"access_token": access_token, "token_type": "bearer"}) response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie( response.set_cookie(
"cookie_access_token", "cookie_access_token", access_token, httponly=True, max_age=max_age
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") response.delete_cookie("is_access_token_expired")
return response return response
@@ -617,32 +590,19 @@ def _auth_api_token_response(
sub=username, api_token_id=api_token_id, auth_time=int(time()) sub=username, api_token_id=api_token_id, auth_time=int(time())
) )
return create_access_token( return create_access_token(
data=payload.model_dump(), token_expire_minutes=token_expire_minutes data=payload.dict(), token_expire_minutes=token_expire_minutes
) )
def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse: def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
payload = AccessTokenPayload( payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
usr=user_id, sub="", email=email, auth_time=int(time()) access_token = create_access_token(data=payload.dict())
)
access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path) response = RedirectResponse(path)
response.set_cookie( response.set_cookie(
"cookie_access_token", "cookie_access_token", access_token, httponly=True, max_age=max_age
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") response.delete_cookie("is_access_token_expired")
return response return response
@@ -662,10 +622,7 @@ def _new_sso(provider: str) -> SSOBase | None:
sso_provider_class = _find_auth_provider_class(provider) sso_provider_class = _find_auth_provider_class(provider)
sso_provider = sso_provider_class( sso_provider = sso_provider_class(
client_id, client_id, client_secret, None, allow_insecure_http=True
client_secret,
None,
allow_insecure_http=not settings.auth_https_only,
) )
if ( if (
discovery_url discovery_url
+1 -2
View File
@@ -637,8 +637,7 @@ async def create_extension_review(
) -> ExtensionReviewPaymentRequest: ) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
json=data.model_dump(),
) )
resp.raise_for_status() resp.raise_for_status()
payment_request = resp.json() payment_request = resp.json()
+2 -2
View File
@@ -200,7 +200,7 @@ async def index(
) -> HTMLResponse: ) -> HTMLResponse:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
"base.html", "index.html",
{ {
"user": user.json(), "user": user.json(),
}, },
@@ -211,7 +211,7 @@ async def index(
@generic_router.get("/node/public") @generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) @generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse: async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "base.html", {"public": True}) return template_renderer().TemplateResponse(request, "index.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
+37 -131
View File
@@ -8,20 +8,10 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from types import UnionType from typing import Any, Generic, Literal, TypeVar, get_origin
from typing import (
Any,
ClassVar,
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text from sqlalchemy.sql import text
@@ -30,7 +20,6 @@ from lnbits.settings import settings
POSTGRES = "POSTGRES" POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH" COCKROACH = "COCKROACH"
SQLITE = "SQLITE" SQLITE = "SQLITE"
PYDANTIC_MODEL_TYPES = (BaseModel,)
DateTrunc = Literal["hour", "day", "month"] DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = { sqlite_formats = {
@@ -39,72 +28,6 @@ sqlite_formats = {
"month": "%Y-%m-01 00:00:00", "month": "%Y-%m-01 00:00:00",
} }
def _is_pydantic_model_class(model: Any) -> bool:
return isinstance(model, type) and any(
issubclass(model, model_type) for model_type in PYDANTIC_MODEL_TYPES
)
def _issubclass(candidate: Any, parent: Any) -> bool:
return isinstance(candidate, type) and issubclass(candidate, parent)
def _strip_optional(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin not in {Union, UnionType}:
return annotation
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
return _strip_optional(args[0])
return annotation
def _model_fields(model: type[Any]) -> dict[str, Any]:
fields = getattr(model, "model_fields", None)
if fields is not None:
return fields
return getattr(model, "__fields__", {})
def _field_annotation(field: Any) -> Any:
annotation = getattr(field, "annotation", None)
if annotation is None:
annotation = getattr(field, "outer_type_", Any)
return _strip_optional(annotation)
def _field_inner_type(field: Any) -> Any:
type_ = getattr(field, "type_", None)
if type_ is not None:
return _strip_optional(type_)
annotation = _field_annotation(field)
origin = get_origin(annotation)
if origin in {list, set, tuple, dict}:
args = get_args(annotation)
return _strip_optional(args[0]) if args else Any
return annotation
def _field_extra(field: Any) -> dict[str, Any]:
field_info = getattr(field, "field_info", None)
if field_info is not None:
return getattr(field_info, "extra", {})
return getattr(field, "json_schema_extra", None) or {}
def _model_dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump()
def _validate_model(model: type[Any], values: dict[str, Any]) -> Any:
return model.model_validate(values)
if settings.lnbits_database_url: if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"): if database_uri.startswith("cockroachdb://"):
@@ -112,7 +35,7 @@ if settings.lnbits_database_url:
else: else:
if not database_uri.startswith("postgres://"): if not database_uri.startswith("postgres://"):
raise ValueError( raise ValueError(
"Please use the 'postgres://...' format for the database URL." "Please use the 'postgres://...' " "format for the database URL."
) )
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
@@ -524,8 +447,8 @@ class Operator(Enum):
class FilterModel(BaseModel): class FilterModel(BaseModel):
__search_fields__: ClassVar[list[str]] = [] __search_fields__: list[str] = []
__sort_fields__: ClassVar[list[str] | None] = None __sort_fields__: list[str] | None = None
T = TypeVar("T") T = TypeVar("T")
@@ -539,12 +462,10 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
model_config = ConfigDict(arbitrary_types_allowed=True)
table_name: str | None = None table_name: str | None = None
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
model: type[TFilterModel] | None = Field(default=None, exclude=True) model: type[TFilterModel] | None
values: dict | None = None values: dict | None = None
@classmethod @classmethod
@@ -566,17 +487,16 @@ class Filter(BaseModel, Generic[TFilterModel]):
field = key field = key
op = Operator("eq") op = Operator("eq")
model_fields = _model_fields(model) if field in model.__fields__:
if field in model_fields: compare_field = model.__fields__[field]
compare_field = model_fields[field]
values: dict = {} values: dict = {}
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}: if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")] raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values): for index, raw_value in enumerate(raw_values):
validated = TypeAdapter( validated, errors = compare_field.validate(raw_value, {}, loc="none")
_field_annotation(compare_field) if errors:
).validate_python(raw_value) raise ValidationError(errors=[errors], model=model)
values[f"{field}__{index}"] = validated values[f"{field}__{index}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@@ -588,10 +508,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
prefix = f"{self.table_name}." if self.table_name else "" prefix = f"{self.table_name}." if self.table_name else ""
stmt = [] stmt = []
for key in self.values.keys() if self.values else []: for key in self.values.keys() if self.values else []:
if ( if self.model and self.model.__fields__[self.field].type_ == datetime:
self.model
and _field_inner_type(_model_fields(self.model)[self.field]) == datetime
):
placeholder = compat_timestamp_placeholder(key) placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}") stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}: if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
@@ -617,9 +534,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
the values can be validated. Otherwise, make sure to validate the inputs manually. the values can be validated. Otherwise, make sure to validate the inputs manually.
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) filters: list[Filter[TFilterModel]] = []
filters: list[Filter[TFilterModel]] = Field(default_factory=list)
search: str | None = None search: str | None = None
offset: int | None = None offset: int | None = None
@@ -627,20 +542,18 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: str | None = None sortby: str | None = None
direction: Literal["asc", "desc"] | None = None direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = Field(default=None, exclude=True) model: type[TFilterModel] | None = None
table_name: str | None = Field(default=None, exclude=True) table_name: str | None = None
@model_validator(mode="before") @root_validator(pre=True)
@classmethod def validate_sortby(cls, values):
def validate_sortby(cls, values: Any):
if not isinstance(values, dict):
return values
sortby = values.get("sortby") sortby = values.get("sortby")
model = values.get("model") model = values.get("model")
if sortby and model: if sortby and model:
model = values["model"]
# if no sort fields are specified explicitly all fields are allowed # if no sort fields are specified explicitly all fields are allowed
allowed = model.__sort_fields__ or _model_fields(model).keys() allowed = model.__sort_fields__ or model.__fields__
if sortby not in allowed: if sortby not in allowed:
raise ValueError("Invalid sort field") raise ValueError("Invalid sort field")
return values return values
@@ -700,12 +613,6 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters: for page_filter in self.filters:
page_filter.table_name = table_name 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): class DbJsonEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
@@ -754,12 +661,10 @@ def model_to_dict(model: BaseModel) -> dict:
:param model: Pydantic model :param model: Pydantic model
""" """
_dict: dict = {} _dict: dict = {}
model_fields = _model_fields(type(model)) for key, value in model.dict().items():
for key, value in _model_dump(model).items(): type_ = model.__fields__[key].type_
field = model_fields[key] outertype_ = model.__fields__[key].outer_type_
type_ = _field_inner_type(field) if model.__fields__[key].field_info.extra.get("no_database", False):
outertype_ = _field_annotation(field)
if _field_extra(field).get("no_database", False):
continue continue
if isinstance(value, datetime): if isinstance(value, datetime):
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
@@ -770,7 +675,7 @@ def model_to_dict(model: BaseModel) -> dict:
_dict[key] = value.replace(tzinfo=None) _dict[key] = value.replace(tzinfo=None)
continue continue
if ( if (
_is_pydantic_model_class(type_) type(type_) is type(BaseModel)
or type_ is dict or type_ is dict
or get_origin(outertype_) is list or get_origin(outertype_) is list
): ):
@@ -794,50 +699,51 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model) return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
""" """
Convert a dictionary with JSON-encoded nested models to a Pydantic model Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database :param _dict: Dictionary from database
:param model: Pydantic model :param model: Pydantic model
""" """
_dict: dict = {} _dict: dict = {}
model_fields = _model_fields(model)
for key, value in _row.items(): for key, value in _row.items():
if value is None: if value is None:
continue continue
if key not in model_fields: if key not in model.__fields__:
# Somethimes an SQL JOIN will create additional column # Somethimes an SQL JOIN will create additional column
continue continue
field = model_fields[key] type_ = model.__fields__[key].type_
type_ = _field_inner_type(field) outertype_ = model.__fields__[key].outer_type_
outertype_ = _field_annotation(field)
if get_origin(outertype_) is list: if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value _items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [ _dict[key] = [
dict_to_submodel(type_, v) if _is_pydantic_model_class(type_) else v dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items for v in _items
] ]
continue continue
if _issubclass(type_, bool): if issubclass(type_, bool):
_dict[key] = bool(value) _dict[key] = bool(value)
continue continue
if _issubclass(type_, datetime): if issubclass(type_, datetime):
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc) _dict[key] = datetime.fromtimestamp(value, timezone.utc)
else: else:
_dict[key] = value.replace(tzinfo=timezone.utc) _dict[key] = value.replace(tzinfo=timezone.utc)
continue continue
if _is_pydantic_model_class(type_): if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value) _dict[key] = dict_to_submodel(type_, value)
continue continue
# TODO: remove this when all sub models are migrated to Pydantic # TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class) # NOTE: this is for type dict on BaseModel, (used in Payment class)
if (type_ is dict or get_origin(outertype_) is dict) and value: if type_ is dict and value:
_dict[key] = _safe_load_json(value) _dict[key] = _safe_load_json(value)
continue continue
_dict[key] = value _dict[key] = value
continue continue
return _validate_model(model, _dict) _model = model.construct(**_dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
def _safe_load_json(value: str) -> dict: def _safe_load_json(value: str) -> dict:
+8 -5
View File
@@ -6,7 +6,7 @@ from typing import Any, Literal
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
@@ -28,7 +28,8 @@ FiatMethod = Literal["checkout", "subscription"]
class PayPalCheckoutOptions(BaseModel): class PayPalCheckoutOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
success_url: str | None = None success_url: str | None = None
cancel_url: str | None = None cancel_url: str | None = None
@@ -36,14 +37,16 @@ class PayPalCheckoutOptions(BaseModel):
class PayPalSubscriptionOptions(BaseModel): class PayPalSubscriptionOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
checking_id: str | None = None checking_id: str | None = None
payment_request: str | None = None payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel): class PayPalCreateInvoiceOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None checkout: PayPalCheckoutOptions | None = None
@@ -336,7 +339,7 @@ class PayPalWallet(FiatProvider):
self, raw_opts: dict[str, Any] self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None: ) -> PayPalCreateInvoiceOptions | None:
try: try:
return PayPalCreateInvoiceOptions.model_validate(raw_opts) return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e: except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}") logger.warning(f"Invalid PayPal options: {e}")
return None return None
+11 -10
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
@@ -30,7 +30,8 @@ FiatMethod = Literal["checkout", "terminal", "subscription"]
class StripeTerminalOptions(BaseModel): class StripeTerminalOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
capture_method: Literal["automatic", "manual"] = "automatic" capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict) metadata: dict[str, str] = Field(default_factory=dict)
@@ -38,7 +39,8 @@ class StripeTerminalOptions(BaseModel):
class StripeCheckoutOptions(BaseModel): class StripeCheckoutOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
success_url: str | None = None success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict) metadata: dict[str, str] = Field(default_factory=dict)
@@ -46,14 +48,16 @@ class StripeCheckoutOptions(BaseModel):
class StripeSubscriptionOptions(BaseModel): class StripeSubscriptionOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
checking_id: str | None = None checking_id: str | None = None
payment_request: str | None = None payment_request: str | None = None
class StripeCreateInvoiceOptions(BaseModel): class StripeCreateInvoiceOptions(BaseModel):
model_config = ConfigDict(extra="ignore") class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None terminal: StripeTerminalOptions | None = None
@@ -167,10 +171,7 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id), ("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"), ("line_items[0][quantity]", f"{quantity}"),
] ]
subscription_data = { subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
**payment_options.model_dump(),
"alan_action": "subscription",
}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {}) subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata( form_data += self._encode_metadata(
@@ -493,7 +494,7 @@ class StripeWallet(FiatProvider):
self, raw_opts: dict[str, Any] self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None: ) -> StripeCreateInvoiceOptions | None:
try: try:
return StripeCreateInvoiceOptions.model_validate(raw_opts) return StripeCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e: except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}") logger.warning(f"Invalid Stripe options: {e}")
return None return None
+10 -11
View File
@@ -12,6 +12,7 @@ import shortuuid
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from pydantic.schema import field_schema
from starlette.templating import Jinja2Templates from starlette.templating import Jinja2Templates
from lnbits.settings import settings from lnbits.settings import settings
@@ -54,6 +55,7 @@ def static_url_for(static: str, path: str) -> str:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates: def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = [ folders = [
"lnbits/templates", "lnbits/templates",
"lnbits/core/templates",
settings.extension_builder_working_dir_path.as_posix(), settings.extension_builder_working_dir_path.as_posix(),
] ]
@@ -70,7 +72,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
# used in base.html # used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().model_dump(by_alias=True) t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys()) t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets: if settings.bundle_assets:
@@ -126,26 +128,23 @@ def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False
:param keep_optional: If false, all parameters will be optional, :param keep_optional: If false, all parameters will be optional,
otherwise inferred from model otherwise inferred from model
""" """
schema = model.model_json_schema() fields = list(model.__fields__.values())
properties = schema.get("properties", {})
required = set(schema.get("required", []))
params = [] params = []
for field_name, field in model.model_fields.items(): for field in fields:
field_key = field.alias or field_name schema, _, _ = field_schema(field, model_name_map={})
field_schema = properties.get(field_key, {})
description = "Supports Filtering" description = "Supports Filtering"
if ( if (
hasattr(model, "__search_fields__") hasattr(model, "__search_fields__")
and field_name in model.__search_fields__ and field.name in model.__search_fields__
): ):
description += ". Supports Search" description += ". Supports Search"
parameter = { parameter = {
"name": field_key, "name": field.alias,
"in": "query", "in": "query",
"required": field_key in required if keep_optional else False, "required": field.required if keep_optional else False,
"schema": field_schema, "schema": schema,
"description": description, "description": description,
} }
params.append(parameter) params.append(parameter)
+1 -1
View File
@@ -364,7 +364,7 @@ class LndRestNode(Node):
fee_report = await self.get("/v1/fees") fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels") balance = await self.get("/v1/balance/channels")
return NodeInfoResponse( return NodeInfoResponse(
**public.model_dump(), **public.dict(),
onchain_balance_sat=onchain["total_balance"], onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"], onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"], balance_msat=balance["local_balance"]["msat"],
+30 -135
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib import importlib
import importlib.metadata import importlib.metadata
import inspect
import json import json
import os import os
import re import re
@@ -10,12 +11,11 @@ from enum import Enum
from os import path from os import path
from pathlib import Path from pathlib import Path
from time import gmtime, strftime, time from time import gmtime, strftime, time
from typing import Any, get_args, get_origin from typing import Any
from uuid import uuid4 from uuid import uuid4
from dotenv import dotenv_values
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, BaseSettings, Extra, Field, validator
def list_parse_fallback(v: str): def list_parse_fallback(v: str):
@@ -29,89 +29,6 @@ def list_parse_fallback(v: str):
return [] return []
def _remove_env_names(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
def _iter_validation_aliases(validation_alias: Any) -> list[str]:
if isinstance(validation_alias, str):
return [validation_alias]
choices = getattr(validation_alias, "choices", None)
if not choices:
return []
return [choice for choice in choices if isinstance(choice, str)]
def _annotation_has_origin(annotation: Any, origins: tuple[type, ...]) -> bool:
origin = get_origin(annotation)
if origin in origins:
return True
return any(_annotation_has_origin(arg, origins) for arg in get_args(annotation))
def _parse_settings_value(value: Any, annotation: Any) -> Any:
if not isinstance(value, str):
return value
stripped_value = value.strip()
if stripped_value.startswith("[") or stripped_value.startswith("{"):
return json.loads(stripped_value)
if _annotation_has_origin(annotation, (list, set, tuple)):
return list_parse_fallback(value)
return value
class LNbitsBaseSettings(BaseModel):
def __init__(self, **data):
super().__init__(**{**self._settings_data(), **data})
@classmethod
def _settings_data(cls) -> dict[str, Any]:
env_file = cls.model_config.get("env_file")
env_file_encoding = cls.model_config.get("env_file_encoding")
case_sensitive = bool(cls.model_config.get("case_sensitive", False))
raw_values: dict[str, Any] = {}
if env_file:
dotenv_items = dotenv_values(env_file, encoding=env_file_encoding)
raw_values.update(
{key: value for key, value in dotenv_items.items() if value is not None}
)
raw_values.update(os.environ)
lookup_values = (
raw_values
if case_sensitive
else {str(key).lower(): value for key, value in raw_values.items()}
)
settings_values: dict[str, Any] = {}
for field_name, field in cls.model_fields.items():
lookup_keys = _iter_validation_aliases(field.validation_alias)
if field.alias and field.alias != field_name:
lookup_keys.append(field.alias)
lookup_keys.append(field_name)
for key in lookup_keys:
lookup_key = key if case_sensitive else key.lower()
if lookup_key not in lookup_values:
continue
settings_values[field_name] = _parse_settings_value(
lookup_values[lookup_key], field.annotation
)
break
return settings_values
class LNbitsSettings(BaseModel): class LNbitsSettings(BaseModel):
@classmethod @classmethod
def validate_list(cls, val): def validate_list(cls, val):
@@ -407,7 +324,7 @@ class AssetSettings(LNbitsSettings):
"heif", "heif",
"heics", "heics",
"text/plain", "text/plain",
"text/jsontext/xml", "text/json" "text/xml",
"application/json", "application/json",
"application/pdf", "application/pdf",
] ]
@@ -735,9 +652,9 @@ class BoltzFundingSource(LNbitsSettings):
class StrikeFundingSource(LNbitsSettings): class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field( strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", validation_alias="STRIKE_API_ENDPOINT" default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
) )
strike_api_key: str | None = Field(default=None, validation_alias="STRIKE_API_KEY") strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel): class FiatProviderLimits(BaseModel):
@@ -815,7 +732,6 @@ class FundingSourcesSettings(
# How long to wait for the payment to be confirmed before returning a pending status # How long to wait for the payment to be confirmed before returning a pending status
# It will not fail the payment, it will make it return pending after the timeout # 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_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) funding_source_max_retries: int = Field(default=4, ge=0)
@@ -880,7 +796,6 @@ class AuthMethods(Enum):
google_auth = "google-auth" google_auth = "google-auth"
github_auth = "github-auth" github_auth = "github-auth"
keycloak_auth = "keycloak-auth" keycloak_auth = "keycloak-auth"
oidc_auth = "oidc-auth"
@classmethod @classmethod
def all(cls): def all(cls):
@@ -891,7 +806,6 @@ class AuthMethods(Enum):
AuthMethods.google_auth.value, AuthMethods.google_auth.value,
AuthMethods.github_auth.value, AuthMethods.github_auth.value,
AuthMethods.keycloak_auth.value, AuthMethods.keycloak_auth.value,
AuthMethods.oidc_auth.value,
] ]
@@ -937,14 +851,6 @@ class KeycloakAuthSettings(LNbitsSettings):
keycloak_client_custom_icon: str | None = Field(default=None) 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): class AuditSettings(LNbitsSettings):
lnbits_audit_enabled: bool = Field(default=True) lnbits_audit_enabled: bool = Field(default=True)
@@ -1052,14 +958,13 @@ class EditableSettings(
GoogleAuthSettings, GoogleAuthSettings,
GitHubAuthSettings, GitHubAuthSettings,
KeycloakAuthSettings, KeycloakAuthSettings,
OidcAuthSettings,
): ):
@field_validator( @validator(
"lnbits_admin_users", "lnbits_admin_users",
"lnbits_allowed_users", "lnbits_allowed_users",
"lnbits_theme_options", "lnbits_theme_options",
"lnbits_admin_extensions", "lnbits_admin_extensions",
mode="before", pre=True,
) )
@classmethod @classmethod
def validate_editable_settings(cls, val): def validate_editable_settings(cls, val):
@@ -1067,30 +972,27 @@ class EditableSettings(
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
return cls(**{k: v for k, v in d.items() if k in cls.model_fields}) return cls(
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
)
# Fixes openapi.json validation by removing the v1-only env_names metadata. # fixes openapi.json validation, remove field env_names
model_config = ConfigDict( class Config:
populate_by_name=True, @staticmethod
json_schema_extra=_remove_env_names, def schema_extra(schema: dict[str, Any]) -> None:
) for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
model_config = ConfigDict( class Config:
populate_by_name=True, extra = Extra.forbid
extra="forbid",
json_schema_extra=_remove_env_names,
)
class EnvSettings(LNbitsSettings): class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False) debug: bool = Field(default=False)
debug_database: bool = Field(default=False) debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1") host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0) port: int = Field(default=5000, gt=0)
forwarded_allow_ips: str = Field(default="*") forwarded_allow_ips: str = Field(default="*")
@@ -1193,7 +1095,7 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in cls.model_fields if not f.startswith("_")] return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
class ReadOnlySettings( class ReadOnlySettings(
@@ -1208,9 +1110,9 @@ class ReadOnlySettings(
def lnbits_extensions_upgrade_path(self) -> str: def lnbits_extensions_upgrade_path(self) -> str:
return str(Path(self.lnbits_data_folder, "upgrades")) return str(Path(self.lnbits_data_folder, "upgrades"))
@field_validator( @validator(
"lnbits_allowed_funding_sources", "lnbits_allowed_funding_sources",
mode="before", pre=True,
) )
@classmethod @classmethod
def validate_readonly_settings(cls, val): def validate_readonly_settings(cls, val):
@@ -1218,18 +1120,15 @@ class ReadOnlySettings(
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in cls.model_fields if not f.startswith("_")] return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
class Settings( class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
EditableSettings, ReadOnlySettings, TransientSettings, LNbitsBaseSettings class Config:
): env_file = ".env"
model_config = ConfigDict( env_file_encoding = "utf-8"
populate_by_name=True, case_sensitive = False
env_file=".env", json_loads = list_parse_fallback
env_file_encoding="utf-8",
case_sensitive=False,
)
def is_user_allowed(self, user_id: str) -> bool: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
@@ -1275,8 +1174,6 @@ class PublicSettings(BaseModel):
auth_methods: list[str] = Field(alias="authMethods") auth_methods: list[str] = Field(alias="authMethods")
keycloak_org: str | None = Field(alias="keycloakOrg") keycloak_org: str | None = Field(alias="keycloakOrg")
keycloak_icon: str | None = Field(alias="keycloakIcon") 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_holdinvoice: bool = Field(alias="hasHoldinvoice")
has_nodemanager: bool = Field(alias="hasNodemanager") has_nodemanager: bool = Field(alias="hasNodemanager")
show_nodemanager: bool = Field(alias="showNodemanager") show_nodemanager: bool = Field(alias="showNodemanager")
@@ -1341,8 +1238,6 @@ class PublicSettings(BaseModel):
authMethods=settings.auth_allowed_methods, authMethods=settings.auth_allowed_methods,
keycloakOrg=settings.keycloak_client_custom_org, keycloakOrg=settings.keycloak_client_custom_org,
keycloakIcon=settings.keycloak_client_custom_icon, keycloakIcon=settings.keycloak_client_custom_icon,
oidcOrg=settings.oidc_client_custom_org,
oidcIcon=settings.oidc_client_custom_icon,
hasHoldinvoice=settings.has_holdinvoice, hasHoldinvoice=settings.has_holdinvoice,
hasNodemanager=settings.has_nodemanager, hasNodemanager=settings.has_nodemanager,
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager, showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
@@ -1434,7 +1329,7 @@ if not settings.user_agent:
# printing environment variable for debugging # printing environment variable for debugging
if not settings.lnbits_admin_ui: if not settings.lnbits_admin_ui:
logger.debug("Environment Settings:") logger.debug("Environment Settings:")
for key, value in settings.model_dump(exclude_none=True).items(): for key, value in settings.dict(exclude_none=True).items():
logger.debug(f"{key}: {value}") logger.debug(f"{key}: {value}")
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
-10
View File
@@ -640,16 +640,6 @@ window.localisation.br = {
auth_keycloak_ci_hint: 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', '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_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_org_label: 'Keycloak Custom Organization',
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)', auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
currency_settings: 'Configurações de Moeda', currency_settings: 'Configurações de Moeda',
-9
View File
@@ -353,15 +353,6 @@ window.localisation.cn = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'确保授权回调URL设置为https://{domain}/api/v1/auth/keycloak/token', '确保授权回调URL设置为https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak客户端密钥', 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: '货币设置', currency_settings: '货币设置',
allowed_currencies: '允许的货币', allowed_currencies: '允许的货币',
allowed_currencies_hint: '限制可用法定货币的数量', allowed_currencies_hint: '限制可用法定货币的数量',
-10
View File
@@ -367,16 +367,6 @@ window.localisation.cs = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Ujistěte se, že je autorizace callback URL nastavena na https://{domain}/api/v1/auth/keycloak/token', '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_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', currency_settings: 'Nastavení měny',
allowed_currencies: 'Povolené měny', allowed_currencies: 'Povolené měny',
allowed_currencies_hint: 'Omezte počet dostupných fiat měn', allowed_currencies_hint: 'Omezte počet dostupných fiat měn',
-10
View File
@@ -377,16 +377,6 @@ window.localisation.de = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Stellen Sie sicher, dass die Autorisierungs-Callback-URL auf https://{domain}/api/v1/auth/keycloak/token eingestellt ist.', '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_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', currency_settings: 'Währungseinstellungen',
allowed_currencies: 'Erlaubte Währungen', allowed_currencies: 'Erlaubte Währungen',
allowed_currencies_hint: allowed_currencies_hint:
-12
View File
@@ -611,10 +611,6 @@ window.localisation.en = {
payment_timeouts: 'Payment Timeouts', payment_timeouts: 'Payment Timeouts',
payment_wait_time: 'Payment Wait Time', payment_wait_time: 'Payment Wait Time',
seconds: 'seconds', 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: payment_wait_time_desc:
'Wait time before marking an outgoing payment as pending. Default: 5s; raise for slow-settling invoices.', 'Wait time before marking an outgoing payment as pending. Default: 5s; raise for slow-settling invoices.',
payment_wait_time_tooltip: payment_wait_time_tooltip:
@@ -646,14 +642,6 @@ window.localisation.en = {
auth_keycloak_cs_label: 'Keycloak Client Secret', auth_keycloak_cs_label: 'Keycloak Client Secret',
auth_keycloak_custom_org_label: 'Keycloak Custom Organization', auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)', 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', currency_settings: 'Currency Settings',
allowed_currencies: 'Allowed Currencies', allowed_currencies: 'Allowed Currencies',
allowed_currencies_hint: allowed_currencies_hint:
-10
View File
@@ -379,16 +379,6 @@ window.localisation.es = {
auth_keycloak_ci_hint: 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', '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_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', currency_settings: 'Configuración de moneda',
allowed_currencies: 'Monedas permitidas', allowed_currencies: 'Monedas permitidas',
allowed_currencies_hint: allowed_currencies_hint:
-8
View File
@@ -520,14 +520,6 @@ window.localisation.fi = {
auth_keycloak_cs_label: 'Keycloak-asiakassalasana', auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio', auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)', 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', currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat', allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat', allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
-10
View File
@@ -381,16 +381,6 @@ window.localisation.fr = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
"Assurez-vous que l'URL de rappel d'autorisation est définie sur https://{domain}/api/v1/auth/keycloak/token", "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_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', currency_settings: 'Paramètres de devise',
allowed_currencies: 'Devises autorisées', allowed_currencies: 'Devises autorisées',
allowed_currencies_hint: allowed_currencies_hint:
-10
View File
@@ -378,16 +378,6 @@ window.localisation.it = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
"Assicurati che l'URL di callback dell'autorizzazione sia impostato su https://{domain}/api/v1/auth/keycloak/token", "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_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', currency_settings: 'Impostazioni valuta',
allowed_currencies: 'Valute consentite', allowed_currencies: 'Valute consentite',
allowed_currencies_hint: 'Limita il numero di valute fiat disponibili', allowed_currencies_hint: 'Limita il numero di valute fiat disponibili',
-9
View File
@@ -369,15 +369,6 @@ window.localisation.jp = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'認証コールバックURLが https://{domain}/api/v1/auth/keycloak/token に設定されていることを確認してください。', '認証コールバックURLが https://{domain}/api/v1/auth/keycloak/token に設定されていることを確認してください。',
auth_keycloak_cs_label: 'キークローククライアントシークレット', 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: '通貨設定', currency_settings: '通貨設定',
allowed_currencies: '許可されている通貨', allowed_currencies: '許可されている通貨',
allowed_currencies_hint: '利用可能な法定通貨の数を制限する', allowed_currencies_hint: '利用可能な法定通貨の数を制限する',
-10
View File
@@ -365,16 +365,6 @@ window.localisation.kr = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'승인 콜백 URL이 https://{domain}/api/v1/auth/keycloak/token으로 설정되어 있는지 확인하십시오.', '승인 콜백 URL이 https://{domain}/api/v1/auth/keycloak/token으로 설정되어 있는지 확인하십시오.',
auth_keycloak_cs_label: 'Keycloak 클라이언트 시크릿', 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: '통화 설정', currency_settings: '통화 설정',
allowed_currencies: '허용되는 통화', allowed_currencies: '허용되는 통화',
allowed_currencies_hint: '사용 가능한 법정 화폐의 수를 제한하십시오.', allowed_currencies_hint: '사용 가능한 법정 화폐의 수를 제한하십시오.',
-10
View File
@@ -377,16 +377,6 @@ window.localisation.nl = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Zorg ervoor dat de autorisatie callback-URL is ingesteld op https://{domain}/api/v1/auth/keycloak/token', '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_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', currency_settings: 'Valuta-instellingen',
allowed_currencies: "Toegestane valuta's", allowed_currencies: "Toegestane valuta's",
allowed_currencies_hint: "Beperk het aantal beschikbare fiatvaluta's", allowed_currencies_hint: "Beperk het aantal beschikbare fiatvaluta's",
-10
View File
@@ -371,16 +371,6 @@ window.localisation.pi = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
"Make sure thant th' authorization callback URL be set t' https://{domain}/api/v1/auth/keycloak/token", "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_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", currency_settings: "Doubloon Settin's",
allowed_currencies: "Allo'ed Doubloons", allowed_currencies: "Allo'ed Doubloons",
allowed_currencies_hint: 'Limit the number of available fiat doubloons', allowed_currencies_hint: 'Limit the number of available fiat doubloons',
-10
View File
@@ -372,16 +372,6 @@ window.localisation.pl = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Upewnij się, że URL zwrotu autoryzacji jest ustawiony na https://{domain}/api/v1/auth/keycloak/token', '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_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', currency_settings: 'Ustawienia waluty',
allowed_currencies: 'Dozwolone waluty', allowed_currencies: 'Dozwolone waluty',
allowed_currencies_hint: 'Ogranicz liczbę dostępnych walut fiducjarnych', allowed_currencies_hint: 'Ogranicz liczbę dostępnych walut fiducjarnych',
-10
View File
@@ -375,16 +375,6 @@ window.localisation.pt = {
auth_keycloak_ci_hint: 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', '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_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', currency_settings: 'Configurações de Moeda',
allowed_currencies: 'Moedas Permitidas', allowed_currencies: 'Moedas Permitidas',
allowed_currencies_hint: 'Limite o número de moedas fiduciárias disponíveis', allowed_currencies_hint: 'Limite o número de moedas fiduciárias disponíveis',
-10
View File
@@ -371,16 +371,6 @@ window.localisation.sk = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
'Uistite sa, že URL spätného volania autorizácie je nastavená na https://{domain}/api/v1/auth/keycloak/token', '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_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', currency_settings: 'Nastavenia meny',
allowed_currencies: 'Povolené meny', allowed_currencies: 'Povolené meny',
allowed_currencies_hint: 'Obmedzte počet dostupných fiat mien', allowed_currencies_hint: 'Obmedzte počet dostupných fiat mien',
-10
View File
@@ -370,16 +370,6 @@ window.localisation.we = {
auth_keycloak_ci_hint: auth_keycloak_ci_hint:
"Gwnewch yn siŵr bod URL adalw awdurdodiad wedi'i osod i https://{domain}/api/v1/auth/keycloak/token", "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_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', currency_settings: 'Gosodiadau Arian Cyfred',
allowed_currencies: 'Ariannau a Ganiateir', allowed_currencies: 'Ariannau a Ganiateir',
allowed_currencies_hint: 'Cyfyngu nifer yr arian cyfred fiat sydd ar gael', allowed_currencies_hint: 'Cyfyngu nifer yr arian cyfred fiat sydd ar gael',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -1,19 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -59,7 +59,7 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments') newWallet.canSendPayments = perms.includes('send-payments')
} }
newWallet.url = `/wallet?&wal=${data.id}` newWallet.url = `/wallet?&wal=${data.id}`
newWallet.storedPaylinks = data.stored_paylinks?.links newWallet.storedPaylinks = data.stored_paylinks.links
return newWallet return newWallet
} }
} }
+6 -5
View File
@@ -442,8 +442,7 @@ window.app.component('username-password', {
'nostr-auth-nip98', 'nostr-auth-nip98',
'google-auth', 'google-auth',
'github-auth', 'github-auth',
'keycloak-auth', 'keycloak-auth'
'oidc-auth'
], ],
username: this.userName, username: this.userName,
password: this.password_1, password: this.password_1,
@@ -692,10 +691,12 @@ window.app.component('lnbits-node-qrcode', {
<q-card-section> <q-card-section>
<div class="text-h6"> <div class="text-h6">
<div style="text-align: center"> <div style="text-align: center">
<lnbits-qrcode <vue-qrcode
v-if='info.addresses[0]'
:value="info.addresses[0]" :value="info.addresses[0]"
></lnbits-qrcode> :options="{width: 250}"
v-if='info.addresses[0]'
class="rounded-borders"
></vue-qrcode>
<div v-else class='text-subtitle1'> <div v-else class='text-subtitle1'>
No addresses available No addresses available
</div> </div>
-4
View File
@@ -10,10 +10,6 @@ window.PageAccount = {
name: 'bitcoin', name: 'bitcoin',
color: 'deep-orange' color: 'deep-orange'
}, },
{
name: 'classic',
color: 'purple'
},
{ {
name: 'mint', name: 'mint',
color: 'green' color: 'green'
+2 -7
View File
@@ -82,8 +82,9 @@
<!-- scripts libraries --> <!-- scripts libraries -->
{% for url in INCLUDED_JS %} {% for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script> <script src="{{ static_url_for('static', url) }}"></script>
{% endfor %} {% if user %} {% endfor %}
<!-- user init --> <!-- user init -->
{% if user %}
<script> <script>
window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }})); window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }}));
{% if not public %} {% if not public %}
@@ -91,12 +92,6 @@
{% endif %} {% endif %}
</script> </script>
{% endif %} {% endif %}
<!-- app init -->
<script>
window.app = Vue.createApp({
el: '#vue'
})
</script>
<!-- scripts from extensions --> <!-- scripts from extensions -->
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<!-- components js --> <!-- components js -->
-28
View File
@@ -1050,34 +1050,6 @@ include('components/lnbits-error.vue') %}
></span> ></span>
</div> </div>
</q-btn> </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> </div>
</q-card-section> </q-card-section>
</template> </template>
@@ -160,27 +160,6 @@
min="0" min="0"
></q-input> ></q-input>
</div> </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> </div>
<div v-if="isSuperUser"> <div v-if="isSuperUser">
<lnbits-admin-funding-sources <lnbits-admin-funding-sources
@@ -192,57 +192,6 @@
</div> </div>
</div> </div>
</q-card-section> </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-separator></q-separator>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<br /> <br />
+3 -1
View File
@@ -1,4 +1,6 @@
{% extends "base.html" %} {% block page_container %} {% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block
page_container %}
<lnbits-error <lnbits-error
code="{{ status_code | safe }}" code="{{ status_code | safe }}"
message="{{ message | safe }}" message="{{ message | safe }}"
+6 -2
View File
@@ -1,5 +1,9 @@
{% macro window_vars(user) -%} {% macro window_vars(user) -%}
<script> <script>
// deprecated dont use window_vars anymore //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> </script>
{%- endmacro %} {%- endmacro %}
+1 -55
View File
@@ -262,9 +262,7 @@
<div <div
v-if=" v-if="
'google-auth' in g.settings.authMethods || '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" class="col q-pa-sm text-h6"
> >
@@ -312,58 +310,6 @@
<div>GitHub</div> <div>GitHub</div>
</q-btn> </q-btn>
</div> </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> </div>
</q-card-section> </q-card-section>
+9 -6
View File
@@ -438,7 +438,7 @@
<q-card-section> <q-card-section>
<div v-if="selectedRelease.paymentRequest"> <div v-if="selectedRelease.paymentRequest">
<lnbits-qrcode <lnbits-qrcode
:value="'LIGHTNING:' + selectedRelease.paymentRequest.toUpperCase()" :value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedRelease.paymentRequest" :href="'lightning:' + selectedRelease.paymentRequest"
></lnbits-qrcode> ></lnbits-qrcode>
</div> </div>
@@ -836,7 +836,7 @@
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col"> <div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
<lnbits-qrcode <lnbits-qrcode
:value=" :value="
'LIGHTNING:' + 'lightning:' +
selectedExtension.payToEnable.paymentRequest.toUpperCase() selectedExtension.payToEnable.paymentRequest.toUpperCase()
" "
:href=" :href="
@@ -1247,10 +1247,13 @@
<q-dialog v-model="paymentDialog.show" position="top"> <q-dialog v-model="paymentDialog.show" position="top">
<q-card class="q-pa-md lnbits__dialog-card"> <q-card class="q-pa-md lnbits__dialog-card">
<q-card-section> <q-card-section>
<lnbits-qrcode <q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
:value="'LIGHTNING:' + paymentDialog.invoice.toUpperCase()" <lnbits-qrcode
:href="'lightning:' + paymentDialog.invoice" :value="paymentDialog.invoice"
></lnbits-qrcode> :options="{width: 800}"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</q-card-section> </q-card-section>
<q-card-actions align="between"> <q-card-actions align="between">
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn> <q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
+29 -12
View File
@@ -589,16 +589,23 @@
v-if="transactionDetailsDialog.data.bolt11" v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg" class="text-center q-mb-lg"
> >
<lnbits-qrcode <a
:href=" :href="
'lightning:' + 'lightning:' +
transactionDetailsDialog.data.bolt11 transactionDetailsDialog.data.bolt11
" "
:value=" >
'LIGHTNING:' + <q-responsive :ratio="1" class="q-mx-xl">
transactionDetailsDialog.data.bolt11.toUpperCase() <qrcode-vue
" :value="
></lnbits-qrcode> 'lightning:' +
transactionDetailsDialog.data.bolt11.toUpperCase()
"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
<q-btn <q-btn
outline outline
color="grey" color="grey"
@@ -691,15 +698,25 @@
v-if="props.row.bolt11" v-if="props.row.bolt11"
class="text-center q-mb-lg" class="text-center q-mb-lg"
> >
<lnbits-qrcode <a
:value="
'LIGHTNING:' +
props.row.bolt11.toUpperCase()
"
:href=" :href="
'lightning:' + props.row.bolt11 'lightning:' + props.row.bolt11
" "
></lnbits-qrcode> >
<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>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
+1 -1
View File
@@ -438,7 +438,7 @@
<lnbits-qrcode <lnbits-qrcode
v-else v-else
:href="'lightning:' + receive.paymentReq" :href="'lightning:' + receive.paymentReq"
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()" :value="'lightning:' + receive.paymentReq"
> >
</lnbits-qrcode> </lnbits-qrcode>
<div class="text-center"> <div class="text-center">
+1 -1
View File
@@ -69,7 +69,7 @@ def decrypt_content(
1: 1:
] ]
# extract iv and content # 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")) encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii")) iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt # Decrypt
Generated
+1868 -2387
View File
File diff suppressed because it is too large Load Diff
+64 -65
View File
@@ -7,51 +7,50 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" } urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" }
readme = "README.md" readme = "README.md"
dependencies = [ dependencies = [
"bech32~=1.2.0", "bech32==1.2.0",
"click~=8.3.1", "click==8.3.1",
"fastapi~=0.135.0", "fastapi==0.116.1",
"starlette~=1.0.0", "starlette==0.47.1",
"httpx~=0.27.2", "httpx==0.27.2",
"jinja2~=3.1.6", "jinja2==3.1.6",
"lnurl~=2.0.0", "lnurl==0.8.3",
"pydantic~=2.12.0", "pydantic==1.10.26",
"pyqrcode~=1.2.1", "pyqrcode==1.2.1",
"shortuuid~=1.0.13", "shortuuid==1.0.13",
"sse-starlette~=3.3.3", "sse-starlette==2.3.6",
"typing-extensions~=4.15.0", "typing-extensions==4.15.0",
"uvicorn~=0.40.0", "uvicorn==0.40.0",
"sqlalchemy~=1.4.54", "sqlalchemy==1.4.54",
"aiosqlite~=0.22.1", "aiosqlite==0.22.1",
"asyncpg~=0.31.0", "asyncpg==0.31.0",
"uvloop~=0.22.1", "uvloop==0.22.1",
"websockets~=15.0.1", "websockets==15.0.1",
"loguru~=0.7.3", "loguru==0.7.3",
"grpcio~=1.76.0", "grpcio==1.76.0",
"protobuf~=6.33.5", "protobuf==6.33.2",
"pyln-client~=25.12.0", "pyln-client==25.12",
"pywebpush~=2.2.0", "pywebpush==2.2.0",
"slowapi~=0.1.9", "slowapi==0.1.9",
"websocket-client~=1.9.0", "websocket-client==1.9.0",
"pycryptodomex~=3.23.0", "pycryptodomex==3.23.0",
"packaging~=25.0.0", "packaging==25.0",
"bolt11~=2.1.1", "bolt11==2.1.1",
"pyjwt~=2.12.0", "pyjwt==2.10.1",
"itsdangerous~=2.2.0", "itsdangerous==2.2.0",
"fastapi-sso~=0.21.0", "fastapi-sso==0.19.0",
# needed for boltz, lnurldevice, watchonly extensions # needed for boltz, lnurldevice, watchonly extensions
"embit~=0.8.0", "embit==0.8.0",
# needed for scheduler extension # needed for scheduler extension
"python-crontab~=3.3.0", "python-crontab==3.3.0",
"pynostr~=0.7.0", "pynostr==0.7.0",
"python-multipart~=0.0.22", "python-multipart==0.0.21",
"filetype~=1.2.0", "filetype==1.2.0",
"nostr-sdk~=0.44.0", "nostr-sdk==0.44.0",
"bcrypt~=5.0.0", "bcrypt==5.0.0",
"jsonpath-ng~=1.7.0", "jsonpath-ng==1.7.0",
"pillow~=12.1.0", "pillow>=12.1.0",
"python-dotenv~=1.2.1", "python-dotenv>=1.2.1",
"greenlet~=3.3.0", "greenlet (>=3.3.0,<4.0.0)",
"pydantic-settings>=2.13.1",
] ]
[project.scripts] [project.scripts]
@@ -59,31 +58,31 @@ lnbits = "lnbits.server:main"
lnbits-cli = "lnbits.commands:main" lnbits-cli = "lnbits.commands:main"
[project.optional-dependencies] [project.optional-dependencies]
breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"] breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
liquid = ["wallycore~=1.5.1"] liquid = ["wallycore==1.5.1"]
migration = ["psycopg2-binary~=2.9.11"] migration = ["psycopg2-binary==2.9.11"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"black~=26.3.1", "black>=25.12.0,<26.0.0",
"mypy~=1.17.1", "mypy==1.17.1",
"types-protobuf~=6.32.1.20251210", "types-protobuf>=6.32.1.20251210,<7.0.0",
"pre-commit~=4.5.1", "pre-commit>=4.5.1,<5.0.0",
"openapi-spec-validator~=0.7.2", "openapi-spec-validator>=0.7.2,<1.0.0",
"ruff~=0.14.10", "ruff>=0.14.10,<1.0.0",
"types-passlib~=1.7.7.20250602", "types-passlib>=1.7.7.20250602,<2.0.0",
"openai~=2.14.0", "openai>=2.14.0",
"json5~=0.13.0", "json5>=0.13.0,<1.0.0",
"asgi-lifespan~=2.1.0", "asgi-lifespan>=2.1.0,<3.0.0",
"anyio~=4.12.1", "anyio>=4.12.1",
"pytest~=9.0.2", "pytest>=9.0.2",
"pytest-cov~=7.0.0", "pytest-cov>=7.0.0",
"pytest-md~=0.2.0", "pytest-md>=0.2.0,<0.3.0",
"pytest-httpserver~=1.1.3", "pytest-httpserver>=1.1.3,<2.0.0",
"pytest-mock~=3.15.1", "pytest-mock>=3.15.1,<4.0.0",
"types-mock~=5.2.0.20250924", "types-mock>=5.2.0.20250924,<6.0.0",
"mock~=5.2.0", "mock>=5.2.0,<6.0.0",
"grpcio-tools~=1.76.0" "grpcio-tools>=1.76.0,<2.0.0"
] ]
[tool.poetry] [tool.poetry]
-1
View File
@@ -43,7 +43,6 @@ def anyio_backend():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def settings(): def settings():
# override settings for tests # override settings for tests
lnbits_settings.auth_https_only = False
lnbits_settings.lnbits_admin_extensions = [] lnbits_settings.lnbits_admin_extensions = []
lnbits_settings.lnbits_data_folder = "./tests/data" lnbits_settings.lnbits_data_folder = "./tests/data"
lnbits_settings.lnbits_admin_ui = True lnbits_settings.lnbits_admin_ui = True
+1 -1
View File
@@ -1,7 +1,7 @@
import random import random
import string import string
from pydantic.v1 import BaseModel from pydantic import BaseModel
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
+6 -6
View File
@@ -4,7 +4,7 @@ from http import HTTPStatus
import pytest import pytest
from httpx import AsyncClient, Headers from httpx import AsyncClient, Headers
from pydantic import TypeAdapter from pydantic import parse_obj_as
from lnbits import bolt11 from lnbits import bolt11
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
@@ -111,7 +111,7 @@ async def test_get_channel(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json()) channels = parse_obj_as(list[NodeChannel], response.json())
ch = random.choice( ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE] [channel for channel in channels if channel.state == ChannelState.ACTIVE]
) )
@@ -121,7 +121,7 @@ async def test_get_channel(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}") response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200 assert response.status_code == 200
channel = TypeAdapter(NodeChannel).validate_python(response.json()) channel = parse_obj_as(NodeChannel, response.json())
assert channel.id == ch.id assert channel.id == ch.id
@@ -131,7 +131,7 @@ async def test_set_channel_fees(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json()) channels = parse_obj_as(list[NodeChannel], response.json())
ch = random.choice( ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE] [channel for channel in channels if channel.state == ChannelState.ACTIVE]
@@ -146,7 +146,7 @@ async def test_set_channel_fees(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}") response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200 assert response.status_code == 200
channel = TypeAdapter(NodeChannel).validate_python(response.json()) channel = parse_obj_as(NodeChannel, response.json())
assert channel.fee_ppm == 69 assert channel.fee_ppm == 69
assert channel.fee_base_msat == 42 assert channel.fee_base_msat == 42
@@ -162,7 +162,7 @@ async def test_channel_management(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
return TypeAdapter(list[NodeChannel]).validate_python(response.json()) return parse_obj_as(list[NodeChannel], response.json())
data = await get_channels() data = await get_channels()
close = random.choice( close = random.choice(
-111
View File
@@ -1,111 +0,0 @@
import pytest
from pydantic import TypeAdapter, ValidationError
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.core.models.payments import PaymentFilters
from lnbits.core.models.users import UserLabel
from lnbits.core.models.wallets import (
Wallet,
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.db import Filter, Page, dict_to_model
from lnbits.nodes.base import NodePayment
def test_user_label_uses_pydantic_v2_pattern_validation():
label = UserLabel(name="label-1", color="#FF00AA")
assert label.name == "label-1"
assert label.color == "#FF00AA"
with pytest.raises(ValidationError):
UserLabel(name="label-1", color="bad-color")
def test_wallet_has_stored_paylinks_field_and_mirrors_it():
source_wallet = Wallet(
id="source-wallet-id",
user="source-user-id",
name="source",
adminkey="admin-key",
inkey="invoice-key",
)
source_wallet.stored_paylinks.links.append(
StoredPayLink(lnurl="lnurl1example", label="saved paylink")
)
shared_wallet = Wallet(
id="shared-wallet-id",
user="shared-user-id",
name="shared",
adminkey="shared-admin-key",
inkey="shared-invoice-key",
)
source_wallet.extra.shared_with.append(
WalletSharePermission(
request_id="share-request-id",
username="shared-user",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
)
shared_wallet.mirror_shared_wallet(source_wallet)
assert shared_wallet.stored_paylinks.links[0].lnurl == "lnurl1example"
assert shared_wallet.stored_paylinks.links[0].label == "saved paylink"
def test_db_dict_to_model_parses_optional_nested_pydantic_v2_models():
ext = dict_to_model(
{
"id": "ext-id",
"name": "Extension",
"version": "1.0.0",
"active": 1,
"meta": (
'{"installed_release": {"name": "Release", "version": "1.0.0", '
'"archive": "https://example.com/release.zip", '
'"source_repo": "lnbits/example"}, "payments": [], '
'"dependencies": [], "featured": false, '
'"has_paid_release": false, "has_free_release": false}'
),
},
InstallableExtension,
)
assert ext.meta is not None
assert ext.meta.installed_release is not None
assert ext.meta.installed_release.source_repo == "lnbits/example"
def test_page_generic_validates_through_pydantic_v2_type_adapter():
page = Page[NodePayment](
data=[
NodePayment(
pending=False,
amount=1,
time=1,
preimage="preimage",
payment_hash="payment-hash",
)
],
total=1,
)
validated = TypeAdapter(Page[NodePayment]).validate_python(
page, from_attributes=True
)
assert validated.total == 1
assert validated.data[0].payment_hash == "payment-hash"
def test_filter_parse_query_uses_pydantic_v2_field_validation():
parsed = Filter.parse_query("amount[eq]", ["42"], PaymentFilters)
assert parsed.field == "amount"
assert parsed.values == {"amount__0": 42}
+4 -158
View File
@@ -2,16 +2,10 @@ from uuid import uuid4
import pytest import pytest
from lnbits.core.crud.users import ( from lnbits.core.crud.users import get_user_from_account
create_account, from lnbits.core.crud.wallets import delete_wallet, get_wallets
delete_account, from lnbits.core.models.users import Account
get_accounts, from lnbits.core.services.users import create_user_account
get_user_from_account,
)
from lnbits.core.crud.wallets import delete_wallet, force_delete_wallet, get_wallets
from lnbits.core.models.users import Account, AccountFilters
from lnbits.core.services.users import create_user_account, create_user_account_no_ckeck
from lnbits.db import Filter, Filters, Operator
@pytest.mark.anyio @pytest.mark.anyio
@@ -42,151 +36,3 @@ async def test_get_user_from_account_is_wallet_created():
assert ( assert (
len(user.wallets) == 1 len(user.wallets) == 1
), "A new wallet should be created for the user if none exist after deletion" ), "A new wallet should be created for the user if none exist after deletion"
@pytest.mark.anyio
async def test_get_accounts_success_flow():
# Create a new account
username = f"user_{uuid4().hex[:8]}"
account = Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
)
await create_account(account)
# Should return the created account
filters = Filters[AccountFilters](filters=[], model=AccountFilters)
filters.sortby = "created_at"
filters.direction = "desc"
page = await get_accounts(filters=filters)
assert page.total >= 1
found = any(a.username == username for a in page.data)
assert found
await delete_account(account.id)
@pytest.mark.anyio
async def test_get_accounts_with_wallet_id_filter():
# Create account and wallet
username = f"user_{uuid4().hex[:8]}"
account = Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
)
await create_user_account_no_ckeck(account)
wallets = await get_wallets(account.id, deleted=False)
assert wallets
wallet = wallets[0]
# Filter by wallet_id
filters = Filters[AccountFilters](
filters=[
Filter(
field="wallet_id",
op=Operator.EQ,
model=AccountFilters,
values={"wallet_id__0": wallet.id},
)
],
model=AccountFilters,
)
page = await get_accounts(filters=filters)
assert page.total == 1
assert page.data[0].id == account.id
await delete_account(account.id)
@pytest.mark.anyio
async def test_get_accounts_wallet_id_not_found():
filters = Filters[AccountFilters](
filters=[
Filter(
field="wallet_id",
op=Operator.EQ,
model=AccountFilters,
values={"wallet_id__0": uuid4().hex},
)
],
model=AccountFilters,
)
page = await get_accounts(filters=filters)
assert page.total == 0
assert page.data == []
@pytest.mark.anyio
async def test_get_accounts_empty_filters():
# Should not raise, should return a Page
page = await get_accounts()
assert hasattr(page, "data")
assert hasattr(page, "total")
@pytest.mark.anyio
async def test_get_accounts_with_deleted_wallet():
# Create account and wallet, then delete wallet
username = f"user_{uuid4().hex[:8]}"
account = Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
)
await create_user_account_no_ckeck(account)
wallets = await get_wallets(account.id, deleted=False)
assert wallets
wallet = wallets[0]
await delete_wallet(user_id=account.id, wallet_id=wallet.id)
filters = Filters[AccountFilters](
filters=[
Filter(
field="wallet_id",
op=Operator.EQ,
model=AccountFilters,
values={"wallet_id__0": wallet.id},
)
],
model=AccountFilters,
)
page = await get_accounts(filters=filters)
assert page.total == 1
assert page.data[0].id == account.id
await force_delete_wallet(wallet_id=wallet.id)
filters = Filters[AccountFilters](
filters=[
Filter(
field="wallet_id",
op=Operator.EQ,
model=AccountFilters,
values={"wallet_id__0": wallet.id},
)
],
model=AccountFilters,
)
page = await get_accounts(filters=filters)
assert page.total == 0
assert page.data == []
@pytest.mark.anyio
async def test_get_accounts_group_by_and_pagination():
# Create multiple accounts
accounts = []
for _ in range(3):
username = f"user_{uuid4().hex[:8]}"
account = Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
)
await create_user_account_no_ckeck(account)
accounts.append(account)
filters = Filters[AccountFilters](model=AccountFilters, limit=2, offset=0)
page = await get_accounts(filters=filters)
assert page.total >= 3
assert len(page.data) <= 2
+8 -4
View File
@@ -6,21 +6,25 @@ from tests.helpers import DbTestModel
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def fetch_page(db): async def fetch_page(db):
await db.execute("DROP TABLE IF EXISTS test_db_fetch_page") await db.execute("DROP TABLE IF EXISTS test_db_fetch_page")
await db.execute(""" await db.execute(
"""
CREATE TABLE test_db_fetch_page ( CREATE TABLE test_db_fetch_page (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
value TEXT NOT NULL, value TEXT NOT NULL,
name TEXT NOT NULL name TEXT NOT NULL
) )
""") """
await db.execute(""" )
await db.execute(
"""
INSERT INTO test_db_fetch_page (id, name, value) VALUES INSERT INTO test_db_fetch_page (id, name, value) VALUES
('1', 'Alice', 'foo'), ('1', 'Alice', 'foo'),
('2', 'Bob', 'bar'), ('2', 'Bob', 'bar'),
('3', 'Carol', 'bar'), ('3', 'Carol', 'bar'),
('4', 'Dave', 'bar'), ('4', 'Dave', 'bar'),
('5', 'Dave', 'foo') ('5', 'Dave', 'foo')
""") """
)
yield yield
await db.execute("DROP TABLE test_db_fetch_page") await db.execute("DROP TABLE test_db_fetch_page")
+1 -59
View File
@@ -1,7 +1,6 @@
import pytest import pytest
from pydantic import ValidationError
from lnbits.settings import EditableSettings, RedirectPath, Settings, UpdateSettings from lnbits.settings import RedirectPath
lnurlp_redirect_path = { lnurlp_redirect_path = {
"from_path": "/.well-known/lnurlp", "from_path": "/.well-known/lnurlp",
@@ -167,60 +166,3 @@ def test_redirect_path_new_path_from(lnurlp: RedirectPath):
lnurlp.new_path_from("/.well-known/lnurlp/path/more") lnurlp.new_path_from("/.well-known/lnurlp/path/more")
== "/lnurlp/api/v1/well-known/path/more" == "/lnurlp/api/v1/well-known/path/more"
) )
def test_settings_loads_env_and_dotenv_values(tmp_path, monkeypatch):
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"lnbits_admin_users=alice,bob",
"STRIKE_API_ENDPOINT=https://dotenv.strike.example",
]
),
encoding="utf-8",
)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("LNBITS_ALLOWED_USERS", ' ["carol", "dave"] ')
monkeypatch.setenv("STRIKE_API_KEY", "secret-key")
monkeypatch.setenv("STRIKE_API_ENDPOINT", "https://env.strike.example")
settings = Settings()
assert settings.lnbits_admin_users == ["alice", "bob"]
assert settings.lnbits_allowed_users == ["carol", "dave"]
assert settings.strike_api_endpoint == "https://env.strike.example"
assert settings.strike_api_key == "secret-key"
def test_editable_settings_from_dict_ignores_unknown_fields():
editable_settings = EditableSettings.from_dict(
{"lnbits_admin_users": ["alice"], "unknown_setting": "ignored"}
)
assert editable_settings.lnbits_admin_users == ["alice"]
assert "unknown_setting" not in editable_settings.model_dump()
def test_update_settings_splits_string_lists_and_forbids_extra_fields():
updated = UpdateSettings.model_validate(
{
"lnbits_admin_users": "alice,bob",
"strike_api_endpoint": "https://api.strike.me/v1",
"strike_api_key": "secret-key",
}
)
assert updated.lnbits_admin_users == ["alice", "bob"]
assert updated.strike_api_endpoint == "https://api.strike.me/v1"
assert updated.strike_api_key == "secret-key"
with pytest.raises(ValidationError):
UpdateSettings.model_validate({"unknown_setting": True})
def test_update_settings_schema_does_not_include_env_names():
schema = UpdateSettings.model_json_schema()
assert "properties" in schema
assert all("env_names" not in prop for prop in schema["properties"].values())
+1 -1
View File
@@ -1,4 +1,4 @@
from pydantic.v1 import BaseModel from pydantic import BaseModel
class FundingSourceConfig(BaseModel): class FundingSourceConfig(BaseModel):
+1 -1
View File
@@ -40,7 +40,7 @@ def encrypt_content(priv_key, dest_pub_key, content):
def decrypt_content(priv_key, source_pub_key, content): def decrypt_content(priv_key, source_pub_key, content):
p = PublicKey(bytes.fromhex("02" + source_pub_key)) p = PublicKey(bytes.fromhex("02" + source_pub_key))
shared = p.multiply(bytes.fromhex(priv_key)).format()[1:] shared = p.multiply(bytes.fromhex(priv_key)).format()[1:]
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")) encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii")) iv = base64.b64decode(iv_b64.encode("ascii"))
aes = AES.new(shared, AES.MODE_CBC, iv) aes = AES.new(shared, AES.MODE_CBC, iv)
+8 -4
View File
@@ -136,10 +136,12 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
assert os.path.isfile(file), f"{file} does not exist!" assert os.path.isfile(file), f"{file} does not exist!"
sqlite_cursor = get_sqlite_cursor(file) sqlite_cursor = get_sqlite_cursor(file)
tables = sqlite_cursor.execute(""" tables = sqlite_cursor.execute(
"""
SELECT name FROM sqlite_master SELECT name FROM sqlite_master
WHERE type='table' AND name not like 'sqlite?_%' escape '?' WHERE type='table' AND name not like 'sqlite?_%' escape '?'
""").fetchall() """
).fetchall()
for table in tables: for table in tables:
table_name = table[0] table_name = table[0]
@@ -182,9 +184,11 @@ def build_table_columns(file: str, schema: str, table_name: str):
sqlite_columns = sqlite_cursor.execute( sqlite_columns = sqlite_cursor.execute(
f"PRAGMA table_info({table_name})" f"PRAGMA table_info({table_name})"
).fetchall() ).fetchall()
pg_cursor.execute(f""" pg_cursor.execute(
f"""
SELECT table_name, column_name, udt_name FROM information_schema.columns SELECT table_name, column_name, udt_name FROM information_schema.columns
WHERE table_schema = '{schema}'AND table_name = '{table_name}';""") WHERE table_schema = '{schema}'AND table_name = '{table_name}';"""
)
pg_columns = pg_cursor.fetchall() pg_columns = pg_cursor.fetchall()
columns = [] columns = []
Generated
+944 -1126
View File
File diff suppressed because it is too large Load Diff