Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
313bd3f647 | ||
|
|
13a93836d9 | ||
|
|
4f76d0483e | ||
|
|
719d86aa9c | ||
|
|
bbad4a91ae | ||
|
|
fcebb7e28c | ||
|
|
ce5aa4c8a7 | ||
|
|
75bae67446 | ||
|
|
8c184356ef | ||
|
|
efc0547271 | ||
|
|
7a393b11fd | ||
|
|
fae3eca3c7 | ||
|
|
3398070dd5 | ||
|
|
7d98c6b080 | ||
|
|
5f4e889d3a | ||
|
|
fe774ae1a1 | ||
|
|
21e02cb20e | ||
|
|
a3d7b463ae | ||
|
|
e1e2112461 | ||
|
|
92fc9a130f | ||
|
|
e15f3cd207 | ||
|
|
041b358310 | ||
|
|
cbd3de88c4 | ||
|
|
156ab10ad6 |
@@ -94,7 +94,7 @@ AUTH_SECRET_KEY=""
|
||||
######################################
|
||||
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
@@ -271,6 +271,50 @@ KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# OIDC OAuth Config
|
||||
# Generic OIDC provider configuration
|
||||
# Make sure that the redirect URI in your OIDC provider is set to: https://{domain}/api/v1/auth/oidc/token
|
||||
# Required scopes: openid, email, profile
|
||||
# The discovery URL must be accessible from your LNbits server
|
||||
# Always use HTTPS in production environments
|
||||
# The CUSTOM_ORG and CUSTOM_ICON settings allow you to customize the login button
|
||||
# For example: "Login via Zitadel" with the Zitadel logo
|
||||
OIDC_DISCOVERY_URL=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_CLIENT_CUSTOM_ORG=""
|
||||
OIDC_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# Example OIDC configurations for various providers:
|
||||
#
|
||||
# ZITADEL:
|
||||
# OIDC_DISCOVERY_URL=https://login.yourdomain.de/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-zitadel-client-id@project-id
|
||||
# OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Zitadel
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/zitadel.png
|
||||
#
|
||||
# AUTHENTIK:
|
||||
# OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/lnbits/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authentik-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authentik
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authentik.png
|
||||
#
|
||||
# AUTHELIA:
|
||||
# OIDC_DISCOVERY_URL=https://auth.yourdomain.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authelia-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authelia-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authelia
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authelia.png
|
||||
#
|
||||
# OKTA:
|
||||
# OIDC_DISCOVERY_URL=https://your-domain.okta.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-okta-client-id
|
||||
# OIDC_CLIENT_SECRET=your-okta-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Okta
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/okta.png
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: bundle
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- run: make bundle
|
||||
- name: Commit and push bundle changes
|
||||
run: |
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
@@ -16,7 +15,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -30,7 +29,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -58,7 +57,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -74,7 +73,7 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
@@ -94,7 +93,11 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
bundle:
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||
uses: ./.github/workflows/bundle.yml
|
||||
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
AUTH_HTTPS_ONLY: false
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
|
||||
@@ -6,53 +6,30 @@ jobs:
|
||||
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
npm: true
|
||||
|
||||
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
bundle:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
|
||||
poetry:
|
||||
uses: ./.github/workflows/poetry.yml
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
node-version: ["24.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
@@ -14,11 +14,11 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.10
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN uv sync --all-extras
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES="10"
|
||||
|
||||
# Boltzd
|
||||
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
||||
|
||||
@@ -2,15 +2,17 @@ ARG LNBITS_TAG=latest
|
||||
|
||||
FROM lnbits/lnbits:${LNBITS_TAG}
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
|
||||
RUN apt-get update && apt-get -y upgrade
|
||||
RUN apt-get install -y netcat-openbsd npm nodejs git
|
||||
RUN apt-get install -y ca-certificates curl gnupg netcat-openbsd git nodejs
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# install sparksidecar
|
||||
RUN git clone https://github.com/lnbits/spark_sidecar
|
||||
RUN cd spark_sidecar && npm install
|
||||
RUN cd spark_sidecar && npm ci
|
||||
|
||||
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
|
||||
RUN uv sync --all-extras
|
||||
@@ -22,7 +24,7 @@ ENV LNBITS_BACKEND_WALLET_CLASS="SparkL2Wallet"
|
||||
ENV LNBITS_RESERVE_FEE_MIN="20000"
|
||||
ENV LNBITS_RESERVE_FEE_PERCENT="1"
|
||||
ENV LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS="20"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES="10"
|
||||
|
||||
# spark sidecar
|
||||
ENV SPARK_NETWORK="MAINNET"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Generic OIDC Authentication Configuration
|
||||
|
||||
This document explains how to configure generic OIDC authentication for LNbits, which allows integration with various OIDC-compliant authentication providers such as Zitadel, Authentik, and others.
|
||||
|
||||
## Overview
|
||||
|
||||
The generic OIDC provider (`oidc`) complements the existing Keycloak provider and allows you to integrate any OIDC-compliant authentication service. You can customize the login button with your own organization name and icon.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following environment variables to your `.env` file or system environment:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```bash
|
||||
# Enable OIDC authentication
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
|
||||
# OIDC Discovery URL (well-known endpoint)
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
|
||||
# Client credentials from your OIDC provider
|
||||
LNBITS_OIDC_CLIENT_ID=your-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
### Optional Settings - Customize the Login Button
|
||||
|
||||
You can customize how the OIDC login button appears to your users:
|
||||
|
||||
```bash
|
||||
# Custom organization name (displayed on the login button)
|
||||
# Example: "Login via Zitadel" or "Login via Authentik"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
|
||||
# Custom icon URL (displayed on the login button)
|
||||
# Can be a full URL or a path to a local image
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON=https://zitadel.com/favicon.svg
|
||||
```
|
||||
|
||||
If not set, the button will display "Login via OIDC" with a generic lock icon.
|
||||
|
||||
## Zitadel Configuration Example
|
||||
|
||||
For Zitadel, configure as follows:
|
||||
|
||||
1. Create a new application in Zitadel
|
||||
2. Choose "Web" application type
|
||||
3. Configure the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Save the Client ID and Client Secret
|
||||
5. Use these environment variables:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-zitadel-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# Customize the button to show "Login via Zitadel" with Zitadel's logo
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON="https://zitadel.com/favicon.svg"
|
||||
```
|
||||
|
||||
**Result**: The login page will display a button with the text "Login via Zitadel" and the Zitadel logo.
|
||||
|
||||
## Authentik Configuration Example
|
||||
|
||||
For Authentik:
|
||||
|
||||
1. Create a new OAuth2/OpenID Provider
|
||||
2. Set the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
3. Configure scopes: `openid`, `email`, `profile`
|
||||
4. Get the Client ID and Client Secret
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/your-app/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-authentik-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Authentik"
|
||||
```
|
||||
|
||||
## Multiple Auth Methods
|
||||
|
||||
You can enable multiple authentication methods simultaneously:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=username-password,oidc-auth,keycloak-auth
|
||||
```
|
||||
|
||||
## Discovery Endpoint Requirements
|
||||
|
||||
Your OIDC provider must expose a standard discovery endpoint (`.well-known/openid-configuration`) that includes:
|
||||
|
||||
- `authorization_endpoint`
|
||||
- `token_endpoint`
|
||||
- `userinfo_endpoint`
|
||||
- `jwks_uri` (JSON Web Key Set)
|
||||
|
||||
The OIDC implementation will automatically fetch these endpoints from the discovery URL.
|
||||
|
||||
## User Mapping
|
||||
|
||||
The OIDC provider maps user information from the OIDC userinfo endpoint:
|
||||
|
||||
- `sub` → User ID
|
||||
- `email` → Email address
|
||||
- `given_name` → First name
|
||||
- `family_name` → Last name
|
||||
- `name` or `preferred_username` → Display name
|
||||
- `picture` → Profile picture URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication fails
|
||||
|
||||
1. Verify the discovery URL is accessible
|
||||
2. Check that Client ID and Client Secret are correct
|
||||
3. Ensure redirect URI in your OIDC provider matches: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Check LNbits logs for detailed error messages
|
||||
|
||||
### User info not populated
|
||||
|
||||
Some OIDC providers may use different claim names. If user information is not correctly populated, check your provider's userinfo endpoint response format and adjust the provider class if needed.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Keep client secrets secure and never commit them to version control
|
||||
- Use environment variables or secure configuration management
|
||||
- Regularly rotate client secrets
|
||||
- Review OIDC provider's security best practices
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The OIDC provider is implemented in `lnbits/core/models/sso/oidc.py` and extends the `fastapi_sso` library's `SSOBase` class. It uses the standard OpenID Connect flow with:
|
||||
|
||||
- Scopes: `openid`, `email`, `profile`
|
||||
- Response type: `code` (authorization code flow)
|
||||
- Discovery document for automatic endpoint resolution
|
||||
@@ -468,7 +468,12 @@ def register_async_tasks() -> None:
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(
|
||||
run_interval(
|
||||
settings.lnbits_funding_source_pending_interval_seconds,
|
||||
check_pending_payments,
|
||||
)
|
||||
)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
@@ -149,14 +149,12 @@ async def get_payments_paginated( # noqa: C901
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(
|
||||
f"""
|
||||
clause.append(f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
elif failed:
|
||||
@@ -346,14 +344,12 @@ async def get_payments_history(
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
where = [
|
||||
f"""
|
||||
where = [f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
]
|
||||
"""]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
|
||||
@@ -4,7 +4,12 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallets,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
@@ -52,17 +57,22 @@ async def get_accounts(
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
filters = filters or Filters()
|
||||
|
||||
# Make wallet filter explicit
|
||||
wallet_filter = (
|
||||
next((f for f in filters.filters if f.field == "wallet_id"), None)
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
if filters and wallet_filter and wallet_filter.values:
|
||||
where_clauses.append("wallets.id = :wallet_id")
|
||||
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
|
||||
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
|
||||
wallet_filter = filters.get_filter_by_field("wallet_id")
|
||||
|
||||
if wallet_filter and wallet_filter.values:
|
||||
wallet_id_value = next(iter(wallet_filter.values.values()), None)
|
||||
wallet = (
|
||||
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn)
|
||||
if wallet_id_value
|
||||
else None
|
||||
)
|
||||
if not wallet:
|
||||
return Page(data=[], total=0)
|
||||
where_clauses.append("accounts.id = :account_id")
|
||||
values = {**values, "account_id": wallet.user}
|
||||
filters.remove_filter_by_field("wallet_id")
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
|
||||
@@ -10,31 +10,26 @@ from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
"user" TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
@@ -42,10 +37,8 @@ async def m001_initial(db: Connection):
|
||||
|
||||
UNIQUE ("user", extension)
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -53,10 +46,8 @@ async def m001_initial(db: Connection):
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
""")
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
@@ -67,11 +58,9 @@ async def m001_initial(db: Connection):
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -85,8 +74,7 @@ async def m001_initial(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
@@ -149,8 +137,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -164,8 +151,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
@@ -174,8 +160,7 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_check (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
@@ -183,19 +168,16 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_notify (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
@@ -262,19 +244,16 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -282,13 +261,11 @@ async def m009_create_tinyurl_table(db: Connection):
|
||||
wallet TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
@@ -299,8 +276,7 @@ async def m010_create_installed_extensions_table(db: Connection):
|
||||
active BOOLEAN DEFAULT false,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
@@ -309,23 +285,19 @@ async def m011_optimize_balances_view(db: Connection):
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
@@ -345,15 +317,13 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
"""
|
||||
result = await db.execute("""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
""")
|
||||
rows = result.mappings().all()
|
||||
|
||||
for row in rows:
|
||||
@@ -386,8 +356,7 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
@@ -396,8 +365,7 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
@@ -484,8 +452,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -495,8 +462,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
@@ -505,8 +471,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
Important for querying whole lnbits balances.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -516,8 +481,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db: Connection):
|
||||
@@ -536,8 +500,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -549,8 +512,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
@@ -581,8 +543,7 @@ async def m024_drop_pending(db: Connection):
|
||||
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet_id,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -594,8 +555,7 @@ async def m025_refresh_view(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet_id
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m026_update_payment_table(db: Connection):
|
||||
@@ -658,8 +618,7 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
|
||||
async def m028_update_settings(db: Connection):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
@@ -667,8 +626,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
@@ -691,8 +649,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
component TEXT,
|
||||
ip_address TEXT,
|
||||
@@ -706,16 +663,13 @@ async def m029_create_audit_table(db: Connection):
|
||||
delete_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m030_add_user_api_tokens_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
@@ -738,32 +692,25 @@ async def m033_update_payment_table(db: Connection):
|
||||
|
||||
|
||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -776,16 +723,13 @@ async def m037_create_assets_table(db: Connection):
|
||||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m039_index_payments(db: Connection):
|
||||
@@ -804,11 +748,9 @@ async def m039_index_payments(db: Connection):
|
||||
]
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_payments_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m040_index_wallets(db: Connection):
|
||||
@@ -825,11 +767,9 @@ async def m040_index_wallets(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m042_index_accounts(db: Connection):
|
||||
@@ -843,11 +783,9 @@ async def m042_index_accounts(db: Connection):
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SSO authentication providers for LNbits"""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Generic OIDC SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||
|
||||
|
||||
class OidcSSO(SSOBase):
|
||||
"""Class providing login via Generic OIDC OAuth (e.g., Zitadel, Authentik, etc.)"""
|
||||
|
||||
provider = "oidc"
|
||||
scope = ["openid", "email", "profile"]
|
||||
discovery_url = ""
|
||||
|
||||
async def openid_from_response(
|
||||
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||
) -> OpenID:
|
||||
"""Return OpenID from user information provided by OIDC provider"""
|
||||
return OpenID(
|
||||
email=response.get("email", ""),
|
||||
provider=self.provider,
|
||||
id=response.get("sub"),
|
||||
first_name=response.get("given_name"),
|
||||
last_name=response.get("family_name"),
|
||||
display_name=response.get("name") or response.get("preferred_username"),
|
||||
picture=response.get("picture"),
|
||||
)
|
||||
|
||||
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||
"""Get document containing handy urls"""
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await session.get(self.discovery_url)
|
||||
content = response.json()
|
||||
|
||||
return content
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
@@ -154,9 +154,20 @@ async def impersonate_user(
|
||||
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
|
||||
"admin_access_token",
|
||||
cookie_access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_impersonated",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
|
||||
return response
|
||||
|
||||
|
||||
@@ -177,7 +188,12 @@ async def stop_impersonate_user(
|
||||
)
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response.set_cookie(
|
||||
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
admin_access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.delete_cookie("admin_access_token")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
@@ -560,7 +576,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
||||
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
|
||||
)
|
||||
await create_user_account(account)
|
||||
return _auth_redirect_response(redirect_path, email)
|
||||
return _auth_redirect_response(redirect_path, account.id, email)
|
||||
|
||||
|
||||
def _auth_success_response(
|
||||
@@ -575,9 +591,20 @@ def _auth_success_response(
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -594,15 +621,28 @@ def _auth_api_token_response(
|
||||
)
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(
|
||||
usr=user_id, sub="", email=email, auth_time=int(time())
|
||||
)
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
"cookie_access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized",
|
||||
"true",
|
||||
secure=settings.auth_https_only,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
@@ -622,7 +662,10 @@ def _new_sso(provider: str) -> SSOBase | None:
|
||||
|
||||
sso_provider_class = _find_auth_provider_class(provider)
|
||||
sso_provider = sso_provider_class(
|
||||
client_id, client_secret, None, allow_insecure_http=True
|
||||
client_id,
|
||||
client_secret,
|
||||
None,
|
||||
allow_insecure_http=not settings.auth_https_only,
|
||||
)
|
||||
if (
|
||||
discovery_url
|
||||
|
||||
@@ -200,7 +200,7 @@ async def index(
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
"base.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
},
|
||||
@@ -211,7 +211,7 @@ async def index(
|
||||
@generic_router.get("/node/public")
|
||||
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
|
||||
async def index_public(request: Request) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
|
||||
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
|
||||
@@ -36,6 +36,8 @@ lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
try:
|
||||
if "@" in lnurl: # lower case lightning addresses
|
||||
lnurl = lnurl.lower()
|
||||
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
|
||||
if isinstance(res, LnurlErrorResponse):
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
|
||||
|
||||
@@ -613,6 +613,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for page_filter in self.filters:
|
||||
page_filter.table_name = table_name
|
||||
|
||||
def get_filter_by_field(self, field: str) -> Filter[TFilterModel] | None:
|
||||
return next((f for f in self.filters if f.field == field), None)
|
||||
|
||||
def remove_filter_by_field(self, field: str) -> None:
|
||||
self.filters = [f for f in self.filters if f.field != field]
|
||||
|
||||
|
||||
class DbJsonEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
|
||||
@@ -7,15 +7,14 @@ from typing import Any
|
||||
from urllib import request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi.routing import APIRoute
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
from starlette.templating import Jinja2Templates
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
@@ -56,7 +55,6 @@ def static_url_for(static: str, path: str) -> str:
|
||||
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
||||
folders = [
|
||||
"lnbits/templates",
|
||||
"lnbits/core/templates",
|
||||
settings.extension_builder_working_dir_path.as_posix(),
|
||||
]
|
||||
|
||||
@@ -66,7 +64,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
for f in additional_folders
|
||||
]
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t = Jinja2Templates(directory=folders)
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
t.env.globals["normalize_path"] = normalize_path
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import typing
|
||||
|
||||
from jinja2 import BaseLoader, Environment, pass_context
|
||||
from starlette.datastructures import QueryParams
|
||||
from starlette.requests import Request
|
||||
from starlette.templating import Jinja2Templates as SuperJinja2Templates
|
||||
|
||||
|
||||
class Jinja2Templates(SuperJinja2Templates):
|
||||
def __init__(self, loader: BaseLoader) -> None:
|
||||
self.env = self.get_environment(loader)
|
||||
super().__init__(env=self.env)
|
||||
|
||||
def get_environment(self, loader: BaseLoader) -> Environment:
|
||||
@pass_context
|
||||
def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
|
||||
request: Request = context["request"]
|
||||
return request.app.url_path_for(name, **path_params)
|
||||
|
||||
def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams:
|
||||
values = dict(init)
|
||||
values.update(new)
|
||||
return QueryParams(**values)
|
||||
|
||||
env = Environment(loader=loader, autoescape=True)
|
||||
env.globals["url_for"] = url_for
|
||||
env.globals["url_params_update"] = url_params_update
|
||||
return env
|
||||
@@ -76,7 +76,7 @@ class LndRestNode(Node):
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
return self.request("GET", path, **kwargs)
|
||||
return self.request("GET", path, timeout=30, **kwargs)
|
||||
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.get("/v1/getinfo")
|
||||
@@ -99,11 +99,12 @@ class LndRestNode(Node):
|
||||
"perm": True,
|
||||
"timeout": 30,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id, timeout=30)
|
||||
except HTTPException as exc:
|
||||
if "unable to disconnect" in exc.detail:
|
||||
raise HTTPException(
|
||||
@@ -141,6 +142,7 @@ class LndRestNode(Node):
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
@@ -214,6 +216,7 @@ class LndRestNode(Node):
|
||||
# 'min_htlc_msat': <uint64>,
|
||||
# 'inbound_fee': <InboundFee>,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
|
||||
@@ -732,6 +732,7 @@ class FundingSourcesSettings(
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
# It will not fail the payment, it will make it return pending after the timeout
|
||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
||||
lnbits_funding_source_pending_interval_seconds: int = Field(default=1800, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
@@ -796,6 +797,7 @@ class AuthMethods(Enum):
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
keycloak_auth = "keycloak-auth"
|
||||
oidc_auth = "oidc-auth"
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
@@ -806,6 +808,7 @@ class AuthMethods(Enum):
|
||||
AuthMethods.google_auth.value,
|
||||
AuthMethods.github_auth.value,
|
||||
AuthMethods.keycloak_auth.value,
|
||||
AuthMethods.oidc_auth.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -851,6 +854,14 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class OidcAuthSettings(LNbitsSettings):
|
||||
oidc_discovery_url: str = Field(default="")
|
||||
oidc_client_id: str = Field(default="")
|
||||
oidc_client_secret: str = Field(default="")
|
||||
oidc_client_custom_org: str | None = Field(default=None)
|
||||
oidc_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AuditSettings(LNbitsSettings):
|
||||
lnbits_audit_enabled: bool = Field(default=True)
|
||||
|
||||
@@ -958,6 +969,7 @@ class EditableSettings(
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
OidcAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -993,6 +1005,9 @@ class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
# 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")
|
||||
port: int = Field(default=5000, gt=0)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
@@ -1174,6 +1189,8 @@ class PublicSettings(BaseModel):
|
||||
auth_methods: list[str] = Field(alias="authMethods")
|
||||
keycloak_org: str | None = Field(alias="keycloakOrg")
|
||||
keycloak_icon: str | None = Field(alias="keycloakIcon")
|
||||
oidc_org: str | None = Field(alias="oidcOrg")
|
||||
oidc_icon: str | None = Field(alias="oidcIcon")
|
||||
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
|
||||
has_nodemanager: bool = Field(alias="hasNodemanager")
|
||||
show_nodemanager: bool = Field(alias="showNodemanager")
|
||||
@@ -1238,6 +1255,8 @@ class PublicSettings(BaseModel):
|
||||
authMethods=settings.auth_allowed_methods,
|
||||
keycloakOrg=settings.keycloak_client_custom_org,
|
||||
keycloakIcon=settings.keycloak_client_custom_icon,
|
||||
oidcOrg=settings.oidc_client_custom_org,
|
||||
oidcIcon=settings.oidc_client_custom_icon,
|
||||
hasHoldinvoice=settings.has_holdinvoice,
|
||||
hasNodemanager=settings.has_nodemanager,
|
||||
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
|
||||
|
||||
@@ -640,6 +640,16 @@ window.localisation.br = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Certifique-se de que a URL de retorno de chamada de autorização esteja definida para https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Segredo do Cliente Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organização Personalizada do Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de Descoberta do OIDC',
|
||||
auth_oidc_ci_label: 'ID do Cliente OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Certifique-se de que a URL de retorno de chamada de autorização esteja definida para https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Segredo do Cliente OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome da Organização Personalizada OIDC (ex. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Ícone Personalizado do OIDC (URL)',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
currency_settings: 'Configurações de Moeda',
|
||||
|
||||
@@ -353,6 +353,15 @@ window.localisation.cn = {
|
||||
auth_keycloak_ci_hint:
|
||||
'确保授权回调URL设置为https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak客户端密钥',
|
||||
auth_keycloak_custom_org_label: 'Keycloak 自定义组织',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak 自定义图标 (URL)',
|
||||
auth_oidc_label: 'OIDC 发现 URL',
|
||||
auth_oidc_ci_label: 'OIDC 客户端 ID',
|
||||
auth_oidc_ci_hint:
|
||||
'确保授权回调URL设置为https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC客户端密钥',
|
||||
auth_oidc_custom_org_label: 'OIDC 自定义组织名称(例如 Zitadel、Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC 自定义图标 (URL)',
|
||||
currency_settings: '货币设置',
|
||||
allowed_currencies: '允许的货币',
|
||||
allowed_currencies_hint: '限制可用法定货币的数量',
|
||||
|
||||
@@ -367,6 +367,16 @@ window.localisation.cs = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Ujistěte se, že je autorizace callback URL nastavena na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Klíč k aplikaci Keycloak tajemství',
|
||||
auth_keycloak_custom_org_label: 'Vlastní organizace Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Vlastní ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'URL pro zjištění OIDC',
|
||||
auth_oidc_ci_label: 'ID klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Ujistěte se, že je autorizace callback URL nastavena na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Klíč k aplikaci OIDC tajemství',
|
||||
auth_oidc_custom_org_label:
|
||||
'Název vlastní organizace OIDC (např. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Vlastní ikona OIDC (URL)',
|
||||
currency_settings: 'Nastavení měny',
|
||||
allowed_currencies: 'Povolené měny',
|
||||
allowed_currencies_hint: 'Omezte počet dostupných fiat měn',
|
||||
|
||||
@@ -377,6 +377,16 @@ window.localisation.de = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Stellen Sie sicher, dass die Autorisierungs-Callback-URL auf https://{domain}/api/v1/auth/keycloak/token eingestellt ist.',
|
||||
auth_keycloak_cs_label: 'Keycloak-Client-Geheimnis',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Benutzerdefinierte Organisation',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Benutzerdefiniertes Symbol (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery-URL',
|
||||
auth_oidc_ci_label: 'OIDC-Client-ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Stellen Sie sicher, dass die Autorisierungs-Callback-URL auf https://{domain}/api/v1/auth/oidc/token eingestellt ist.',
|
||||
auth_oidc_cs_label: 'OIDC-Client-Geheimnis',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Benutzerdefinierter Organisationsname (z.B. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Benutzerdefiniertes Symbol (URL)',
|
||||
currency_settings: 'Währungseinstellungen',
|
||||
allowed_currencies: 'Erlaubte Währungen',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -611,6 +611,10 @@ window.localisation.en = {
|
||||
payment_timeouts: 'Payment Timeouts',
|
||||
payment_wait_time: 'Payment Wait Time',
|
||||
seconds: 'seconds',
|
||||
payment_pending_interval: 'Check payment interval (sec)',
|
||||
payment_pending_interval_desc: 'Interval to check pending payments',
|
||||
payment_pending_interval_tooltip:
|
||||
'Controls how often LNbits checks for pending payments to update their status. Higher values can reduce the load on the node and speed up the payment process, but it will take longer for pending payments to be updated.',
|
||||
payment_wait_time_desc:
|
||||
'Wait time before marking an outgoing payment as pending. Default: 5s; raise for slow-settling invoices.',
|
||||
payment_wait_time_tooltip:
|
||||
@@ -642,6 +646,14 @@ window.localisation.en = {
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery URL',
|
||||
auth_oidc_ci_label: 'OIDC Client ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Custom Organization Name (e.g., Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Custom Icon (URL)',
|
||||
currency_settings: 'Currency Settings',
|
||||
allowed_currencies: 'Allowed Currencies',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -379,6 +379,16 @@ window.localisation.es = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Asegúrate de que la URL de devolución de llamada de autorización esté configurada en https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Secreto del Cliente de Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organización personalizada de Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icono personalizado de Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de descubrimiento de OIDC',
|
||||
auth_oidc_ci_label: 'ID de cliente de OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Asegúrate de que la URL de devolución de llamada de autorización esté configurada en https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Secreto del Cliente de OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nombre de organización personalizada OIDC (ej. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Icono personalizado de OIDC (URL)',
|
||||
currency_settings: 'Configuración de moneda',
|
||||
allowed_currencies: 'Monedas permitidas',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -520,6 +520,14 @@ window.localisation.fi = {
|
||||
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
|
||||
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
|
||||
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
|
||||
auth_oidc_label: 'OIDC-discovery-URL',
|
||||
auth_oidc_ci_label: 'OIDC-asiakastunnus',
|
||||
auth_oidc_ci_hint:
|
||||
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC-asiakassalasana',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC mukautetun organisaation nimi (esim. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Valinnainen OIDC-kuvake (URL)',
|
||||
currency_settings: 'Valuutta-asetukset',
|
||||
allowed_currencies: 'Käytettävät valuutat',
|
||||
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
|
||||
|
||||
@@ -381,6 +381,16 @@ window.localisation.fr = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Assurez-vous que l'URL de rappel d'autorisation est définie sur https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Secret client Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organisation personnalisée Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icône personnalisée Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de découverte OIDC',
|
||||
auth_oidc_ci_label: 'ID Client OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Assurez-vous que l'URL de rappel d'autorisation est définie sur https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'Secret client OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
"Nom de l'organisation personnalisée OIDC (par ex. Zitadel, Authentik)",
|
||||
auth_oidc_custom_icon_label: 'Icône personnalisée OIDC (URL)',
|
||||
currency_settings: 'Paramètres de devise',
|
||||
allowed_currencies: 'Devises autorisées',
|
||||
allowed_currencies_hint:
|
||||
|
||||
@@ -378,6 +378,16 @@ window.localisation.it = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Assicurati che l'URL di callback dell'autorizzazione sia impostato su https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Organizzazione personalizzata di Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Icona personalizzata di Keycloak (URL)',
|
||||
auth_oidc_label: 'URL di individuazione di OIDC',
|
||||
auth_oidc_ci_label: 'ID client di OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Assicurati che l'URL di callback dell'autorizzazione sia impostato su https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome organizzazione personalizzata OIDC (es. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Icona personalizzata di OIDC (URL)',
|
||||
currency_settings: 'Impostazioni valuta',
|
||||
allowed_currencies: 'Valute consentite',
|
||||
allowed_currencies_hint: 'Limita il numero di valute fiat disponibili',
|
||||
|
||||
@@ -369,6 +369,15 @@ window.localisation.jp = {
|
||||
auth_keycloak_ci_hint:
|
||||
'認証コールバックURLが https://{domain}/api/v1/auth/keycloak/token に設定されていることを確認してください。',
|
||||
auth_keycloak_cs_label: 'キークローククライアントシークレット',
|
||||
auth_keycloak_custom_org_label: 'Keycloak カスタム組織',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak カスタムアイコン (URL)',
|
||||
auth_oidc_label: 'OIDC ディスカバリー URL',
|
||||
auth_oidc_ci_label: 'OIDC クライアント ID',
|
||||
auth_oidc_ci_hint:
|
||||
'認証コールバックURLが https://{domain}/api/v1/auth/oidc/token に設定されていることを確認してください。',
|
||||
auth_oidc_cs_label: 'OIDC クライアントシークレット',
|
||||
auth_oidc_custom_org_label: 'OIDC カスタム組織名(例:Zitadel、Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC カスタムアイコン (URL)',
|
||||
currency_settings: '通貨設定',
|
||||
allowed_currencies: '許可されている通貨',
|
||||
allowed_currencies_hint: '利用可能な法定通貨の数を制限する',
|
||||
|
||||
@@ -365,6 +365,16 @@ window.localisation.kr = {
|
||||
auth_keycloak_ci_hint:
|
||||
'승인 콜백 URL이 https://{domain}/api/v1/auth/keycloak/token으로 설정되어 있는지 확인하십시오.',
|
||||
auth_keycloak_cs_label: 'Keycloak 클라이언트 시크릿',
|
||||
auth_keycloak_custom_org_label: 'Keycloak 사용자 정의 조직',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak 사용자 정의 아이콘 (URL)',
|
||||
auth_oidc_label: 'OIDC 디스커버리 URL',
|
||||
auth_oidc_ci_label: 'OIDC 클라이언트 ID',
|
||||
auth_oidc_ci_hint:
|
||||
'승인 콜백 URL이 https://{domain}/api/v1/auth/oidc/token으로 설정되어 있는지 확인하십시오.',
|
||||
auth_oidc_cs_label: 'OIDC 클라이언트 시크릿',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC 사용자 정의 조직 이름 (예: Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC 사용자 정의 아이콘 (URL)',
|
||||
currency_settings: '통화 설정',
|
||||
allowed_currencies: '허용되는 통화',
|
||||
allowed_currencies_hint: '사용 가능한 법정 화폐의 수를 제한하십시오.',
|
||||
|
||||
@@ -377,6 +377,16 @@ window.localisation.nl = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Zorg ervoor dat de autorisatie callback-URL is ingesteld op https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak Clientgeheim',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Aangepaste Organisatie',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Aangepast Pictogram (URL)',
|
||||
auth_oidc_label: 'OIDC Ontdekking URL',
|
||||
auth_oidc_ci_label: 'OIDC-client-ID',
|
||||
auth_oidc_ci_hint:
|
||||
'Zorg ervoor dat de autorisatie callback-URL is ingesteld op https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'OIDC Clientgeheim',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Aangepaste Organisatienaam (bijv. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Aangepast Pictogram (URL)',
|
||||
currency_settings: 'Valuta-instellingen',
|
||||
allowed_currencies: "Toegestane valuta's",
|
||||
allowed_currencies_hint: "Beperk het aantal beschikbare fiatvaluta's",
|
||||
|
||||
@@ -371,6 +371,16 @@ window.localisation.pi = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Make sure thant th' authorization callback URL be set t' https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
auth_oidc_label: 'OIDC Discovery URL',
|
||||
auth_oidc_ci_label: 'OIDC Client ID',
|
||||
auth_oidc_ci_hint:
|
||||
"Make sure thant th' authorization callback URL be set t' https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'OIDC Client Secret',
|
||||
auth_oidc_custom_org_label:
|
||||
'OIDC Custom Organization Name (e.g., Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'OIDC Custom Icon (URL)',
|
||||
currency_settings: "Doubloon Settin's",
|
||||
allowed_currencies: "Allo'ed Doubloons",
|
||||
allowed_currencies_hint: 'Limit the number of available fiat doubloons',
|
||||
|
||||
@@ -372,6 +372,16 @@ window.localisation.pl = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Upewnij się, że URL zwrotu autoryzacji jest ustawiony na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Hasło klienta Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Własna organizacja Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Własna ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'Adres URL Discovery OIDC',
|
||||
auth_oidc_ci_label: 'Identyfikator klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Upewnij się, że URL zwrotu autoryzacji jest ustawiony na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Hasło klienta OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nazwa własnej organizacji OIDC (np. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Własna ikona OIDC (URL)',
|
||||
currency_settings: 'Ustawienia waluty',
|
||||
allowed_currencies: 'Dozwolone waluty',
|
||||
allowed_currencies_hint: 'Ogranicz liczbę dostępnych walut fiducjarnych',
|
||||
|
||||
@@ -375,6 +375,16 @@ window.localisation.pt = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Certifique-se de que o URL de retorno de chamada de autorização esteja definido como https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Segredo do Cliente do Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Organização Personalizada do Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Ícone Personalizado do Keycloak (URL)',
|
||||
auth_oidc_label: 'URL de Descoberta do OIDC',
|
||||
auth_oidc_ci_label: 'ID do Cliente do OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Certifique-se de que o URL de retorno de chamada de autorização esteja definido como https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Segredo do Cliente do OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Nome da Organização Personalizada OIDC (ex. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Ícone Personalizado do OIDC (URL)',
|
||||
currency_settings: 'Configurações de Moeda',
|
||||
allowed_currencies: 'Moedas Permitidas',
|
||||
allowed_currencies_hint: 'Limite o número de moedas fiduciárias disponíveis',
|
||||
|
||||
@@ -371,6 +371,16 @@ window.localisation.sk = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Uistite sa, že URL spätného volania autorizácie je nastavená na https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Tajný kľúč klienta Keycloak',
|
||||
auth_keycloak_custom_org_label: 'Vlastná organizácia Keycloak',
|
||||
auth_keycloak_custom_icon_label: 'Vlastná ikona Keycloak (URL)',
|
||||
auth_oidc_label: 'URL zistenia OIDC',
|
||||
auth_oidc_ci_label: 'ID klienta OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
'Uistite sa, že URL spätného volania autorizácie je nastavená na https://{domain}/api/v1/auth/oidc/token',
|
||||
auth_oidc_cs_label: 'Tajný kľúč klienta OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
'Názov vlastnej organizácie OIDC (napr. Zitadel, Authentik)',
|
||||
auth_oidc_custom_icon_label: 'Vlastná ikona OIDC (URL)',
|
||||
currency_settings: 'Nastavenia meny',
|
||||
allowed_currencies: 'Povolené meny',
|
||||
allowed_currencies_hint: 'Obmedzte počet dostupných fiat mien',
|
||||
|
||||
@@ -370,6 +370,16 @@ window.localisation.we = {
|
||||
auth_keycloak_ci_hint:
|
||||
"Gwnewch yn siŵr bod URL adalw awdurdodiad wedi'i osod i https://{domain}/api/v1/auth/keycloak/token",
|
||||
auth_keycloak_cs_label: 'Cyfrinach Cleient Keycloak',
|
||||
auth_keycloak_custom_org_label: "Sefydliad Wedi'i Addasu Keycloak",
|
||||
auth_keycloak_custom_icon_label: "Eicon Wedi'i Addasu Keycloak (URL)",
|
||||
auth_oidc_label: 'URL Darganfod OIDC',
|
||||
auth_oidc_ci_label: 'ID Cleient OIDC',
|
||||
auth_oidc_ci_hint:
|
||||
"Gwnewch yn siŵr bod URL adalw awdurdodiad wedi'i osod i https://{domain}/api/v1/auth/oidc/token",
|
||||
auth_oidc_cs_label: 'Cyfrinach Cleient OIDC',
|
||||
auth_oidc_custom_org_label:
|
||||
"Enw Sefydliad Wedi'i Addasu OIDC (e.e. Zitadel, Authentik)",
|
||||
auth_oidc_custom_icon_label: "Eicon Wedi'i Addasu OIDC (URL)",
|
||||
currency_settings: 'Gosodiadau Arian Cyfred',
|
||||
allowed_currencies: 'Ariannau a Ganiateir',
|
||||
allowed_currencies_hint: 'Cyfyngu nifer yr arian cyfred fiat sydd ar gael',
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Background circle -->
|
||||
<circle cx="50" cy="50" r="48" fill="#4A90E2" stroke="#2E5F8E" stroke-width="2"/>
|
||||
|
||||
<!-- Lock body -->
|
||||
<rect x="35" y="45" width="30" height="25" rx="2" fill="#FFFFFF"/>
|
||||
|
||||
<!-- Lock shackle -->
|
||||
<path d="M 40 45 L 40 35 Q 40 25 50 25 Q 60 25 60 35 L 60 45"
|
||||
fill="none" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- Keyhole -->
|
||||
<circle cx="50" cy="55" r="3" fill="#4A90E2"/>
|
||||
<rect x="48.5" y="55" width="3" height="8" fill="#4A90E2"/>
|
||||
|
||||
<!-- ID letters -->
|
||||
<text x="50" y="85" font-family="Arial, sans-serif" font-size="12" font-weight="bold"
|
||||
fill="#FFFFFF" text-anchor="middle">ID</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -442,7 +442,8 @@ window.app.component('username-password', {
|
||||
'nostr-auth-nip98',
|
||||
'google-auth',
|
||||
'github-auth',
|
||||
'keycloak-auth'
|
||||
'keycloak-auth',
|
||||
'oidc-auth'
|
||||
],
|
||||
username: this.userName,
|
||||
password: this.password_1,
|
||||
@@ -691,12 +692,10 @@ window.app.component('lnbits-node-qrcode', {
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<div style="text-align: center">
|
||||
<vue-qrcode
|
||||
:value="info.addresses[0]"
|
||||
:options="{width: 250}"
|
||||
<lnbits-qrcode
|
||||
v-if='info.addresses[0]'
|
||||
class="rounded-borders"
|
||||
></vue-qrcode>
|
||||
:value="info.addresses[0]"
|
||||
></lnbits-qrcode>
|
||||
<div v-else class='text-subtitle1'>
|
||||
No addresses available
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ window.PageAccount = {
|
||||
name: 'bitcoin',
|
||||
color: 'deep-orange'
|
||||
},
|
||||
{
|
||||
name: 'classic',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'mint',
|
||||
color: 'green'
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
exports.noConflict = function () { global._ = current; return exports; };
|
||||
}()));
|
||||
}(this, (function () {
|
||||
// Underscore.js 1.13.7
|
||||
// Underscore.js 1.13.8
|
||||
// https://underscorejs.org
|
||||
// (c) 2009-2024 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
|
||||
// (c) 2009-2026 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
|
||||
// Underscore may be freely distributed under the MIT license.
|
||||
|
||||
// Current version.
|
||||
var VERSION = '1.13.7';
|
||||
var VERSION = '1.13.8';
|
||||
|
||||
// Establish the root object, `window` (`self`) in the browser, `global`
|
||||
// on the server, or `this` in some virtual machines. We use `self`
|
||||
@@ -357,131 +357,146 @@
|
||||
// We use this string twice, so give it a name for minification.
|
||||
var tagDataView = '[object DataView]';
|
||||
|
||||
// Internal recursive comparison function for `_.isEqual`.
|
||||
function eq(a, b, aStack, bStack) {
|
||||
// Identical objects are equal. `0 === -0`, but they aren't identical.
|
||||
// See the [Harmony `egal` proposal](https://wiki.ecmascript.org/doku.php?id=harmony:egal).
|
||||
if (a === b) return a !== 0 || 1 / a === 1 / b;
|
||||
// `null` or `undefined` only equal to itself (strict comparison).
|
||||
if (a == null || b == null) return false;
|
||||
// `NaN`s are equivalent, but non-reflexive.
|
||||
if (a !== a) return b !== b;
|
||||
// Exhaust primitive checks
|
||||
var type = typeof a;
|
||||
if (type !== 'function' && type !== 'object' && typeof b != 'object') return false;
|
||||
return deepEq(a, b, aStack, bStack);
|
||||
}
|
||||
// Perform a deep comparison to check if two objects are equal.
|
||||
function isEqual(a, b) {
|
||||
var todo = [{a: a, b: b}];
|
||||
// Initializing stacks of traversed objects for cycle detection.
|
||||
var aStack = [], bStack = [];
|
||||
|
||||
while (todo.length) {
|
||||
var frame = todo.pop();
|
||||
if (frame === true) {
|
||||
// Remove the first object from the stack of traversed objects.
|
||||
aStack.pop();
|
||||
bStack.pop();
|
||||
continue;
|
||||
}
|
||||
a = frame.a;
|
||||
b = frame.b;
|
||||
|
||||
// Identical objects are equal. `0 === -0`, but they aren't identical.
|
||||
// See the [Harmony `egal` proposal](https://wiki.ecmascript.org/doku.php?id=harmony:egal).
|
||||
if (a === b) {
|
||||
if (a !== 0 || 1 / a === 1 / b) continue;
|
||||
return false;
|
||||
}
|
||||
// `null` or `undefined` only equal to itself (strict comparison).
|
||||
if (a == null || b == null) return false;
|
||||
// `NaN`s are equivalent, but non-reflexive.
|
||||
if (a !== a) {
|
||||
if (b !== b) continue;
|
||||
return false;
|
||||
}
|
||||
// Exhaust primitive checks
|
||||
var type = typeof a;
|
||||
if (type !== 'function' && type !== 'object' && typeof b != 'object') return false;
|
||||
|
||||
// Internal recursive comparison function for `_.isEqual`.
|
||||
function deepEq(a, b, aStack, bStack) {
|
||||
// Unwrap any wrapped objects.
|
||||
if (a instanceof _$1) a = a._wrapped;
|
||||
if (b instanceof _$1) b = b._wrapped;
|
||||
// Compare `[[Class]]` names.
|
||||
var className = toString.call(a);
|
||||
if (className !== toString.call(b)) return false;
|
||||
// Work around a bug in IE 10 - Edge 13.
|
||||
if (hasDataViewBug && className == '[object Object]' && isDataView$1(a)) {
|
||||
if (!isDataView$1(b)) return false;
|
||||
className = tagDataView;
|
||||
}
|
||||
switch (className) {
|
||||
// These types are compared by value.
|
||||
// Unwrap any wrapped objects.
|
||||
if (a instanceof _$1) a = a._wrapped;
|
||||
if (b instanceof _$1) b = b._wrapped;
|
||||
// Compare `[[Class]]` names.
|
||||
var className = toString.call(a);
|
||||
if (className !== toString.call(b)) return false;
|
||||
// Work around a bug in IE 10 - Edge 13.
|
||||
if (hasDataViewBug && className == '[object Object]' && isDataView$1(a)) {
|
||||
if (!isDataView$1(b)) return false;
|
||||
className = tagDataView;
|
||||
}
|
||||
switch (className) {
|
||||
// These types are compared by value.
|
||||
case '[object RegExp]':
|
||||
// RegExps are coerced to strings for comparison (Note: '' + /a/i === '/a/i')
|
||||
case '[object String]':
|
||||
// Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is
|
||||
// equivalent to `new String("5")`.
|
||||
return '' + a === '' + b;
|
||||
if ('' + a === '' + b) continue;
|
||||
return false;
|
||||
case '[object Number]':
|
||||
// `NaN`s are equivalent, but non-reflexive.
|
||||
// Object(NaN) is equivalent to NaN.
|
||||
if (+a !== +a) return +b !== +b;
|
||||
// An `egal` comparison is performed for other numeric values.
|
||||
return +a === 0 ? 1 / +a === 1 / b : +a === +b;
|
||||
todo.push({a: +a, b: +b});
|
||||
continue;
|
||||
case '[object Date]':
|
||||
case '[object Boolean]':
|
||||
// Coerce dates and booleans to numeric primitive values. Dates are compared by their
|
||||
// millisecond representations. Note that invalid dates with millisecond representations
|
||||
// of `NaN` are not equivalent.
|
||||
return +a === +b;
|
||||
if (+a === +b) continue;
|
||||
return false;
|
||||
case '[object Symbol]':
|
||||
return SymbolProto.valueOf.call(a) === SymbolProto.valueOf.call(b);
|
||||
if (SymbolProto.valueOf.call(a) === SymbolProto.valueOf.call(b)) continue;
|
||||
return false;
|
||||
case '[object ArrayBuffer]':
|
||||
case tagDataView:
|
||||
// Coerce to typed array so we can fall through.
|
||||
return deepEq(toBufferView(a), toBufferView(b), aStack, bStack);
|
||||
}
|
||||
todo.push({a: toBufferView(a), b: toBufferView(b)});
|
||||
continue;
|
||||
}
|
||||
|
||||
var areArrays = className === '[object Array]';
|
||||
if (!areArrays && isTypedArray$1(a)) {
|
||||
var areArrays = className === '[object Array]';
|
||||
if (!areArrays && isTypedArray$1(a)) {
|
||||
var byteLength = getByteLength(a);
|
||||
if (byteLength !== getByteLength(b)) return false;
|
||||
if (a.buffer === b.buffer && a.byteOffset === b.byteOffset) return true;
|
||||
if (a.buffer === b.buffer && a.byteOffset === b.byteOffset) continue;
|
||||
areArrays = true;
|
||||
}
|
||||
if (!areArrays) {
|
||||
if (typeof a != 'object' || typeof b != 'object') return false;
|
||||
|
||||
// Objects with different constructors are not equivalent, but `Object`s or `Array`s
|
||||
// from different frames are.
|
||||
var aCtor = a.constructor, bCtor = b.constructor;
|
||||
if (aCtor !== bCtor && !(isFunction$1(aCtor) && aCtor instanceof aCtor &&
|
||||
isFunction$1(bCtor) && bCtor instanceof bCtor)
|
||||
&& ('constructor' in a && 'constructor' in b)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Assume equality for cyclic structures. The algorithm for detecting cyclic
|
||||
// structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.
|
||||
if (!areArrays) {
|
||||
if (typeof a != 'object' || typeof b != 'object') return false;
|
||||
|
||||
// Initializing stack of traversed objects.
|
||||
// It's done here since we only need them for objects and arrays comparison.
|
||||
aStack = aStack || [];
|
||||
bStack = bStack || [];
|
||||
var length = aStack.length;
|
||||
while (length--) {
|
||||
// Linear search. Performance is inversely proportional to the number of
|
||||
// unique nested structures.
|
||||
if (aStack[length] === a) return bStack[length] === b;
|
||||
}
|
||||
// Objects with different constructors are not equivalent, but `Object`s or `Array`s
|
||||
// from different frames are.
|
||||
var aCtor = a.constructor, bCtor = b.constructor;
|
||||
if (aCtor !== bCtor && !(isFunction$1(aCtor) && aCtor instanceof aCtor &&
|
||||
isFunction$1(bCtor) && bCtor instanceof bCtor)
|
||||
&& ('constructor' in a && 'constructor' in b)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the first object to the stack of traversed objects.
|
||||
aStack.push(a);
|
||||
bStack.push(b);
|
||||
// Assume equality for cyclic structures. The algorithm for detecting cyclic
|
||||
// structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.
|
||||
|
||||
// Recursively compare objects and arrays.
|
||||
if (areArrays) {
|
||||
// Compare array lengths to determine if a deep comparison is necessary.
|
||||
length = a.length;
|
||||
if (length !== b.length) return false;
|
||||
// Deep compare the contents, ignoring non-numeric properties.
|
||||
var length = aStack.length;
|
||||
while (length--) {
|
||||
if (!eq(a[length], b[length], aStack, bStack)) return false;
|
||||
// Linear search. Performance is inversely proportional to the number of
|
||||
// unique nested structures.
|
||||
if (aStack[length] === a) {
|
||||
if (bStack[length] === b) break;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deep compare objects.
|
||||
var _keys = keys(a), key;
|
||||
length = _keys.length;
|
||||
// Ensure that both objects contain the same number of properties before comparing deep equality.
|
||||
if (keys(b).length !== length) return false;
|
||||
while (length--) {
|
||||
// Deep compare each member
|
||||
key = _keys[length];
|
||||
if (!(has$1(b, key) && eq(a[key], b[key], aStack, bStack))) return false;
|
||||
if (length >= 0) continue;
|
||||
|
||||
// Add the first object to the stack of traversed objects.
|
||||
aStack.push(a);
|
||||
bStack.push(b);
|
||||
todo.push(true);
|
||||
|
||||
// Recursively compare objects and arrays.
|
||||
if (areArrays) {
|
||||
// Compare array lengths to determine if a deep comparison is necessary.
|
||||
length = a.length;
|
||||
if (length !== b.length) return false;
|
||||
// Deep compare the contents, ignoring non-numeric properties.
|
||||
while (length--) {
|
||||
todo.push({a: a[length], b: b[length]});
|
||||
}
|
||||
} else {
|
||||
// Deep compare objects.
|
||||
var _keys = keys(a), key;
|
||||
length = _keys.length;
|
||||
// Ensure that both objects contain the same number of properties before comparing deep equality.
|
||||
if (keys(b).length !== length) return false;
|
||||
while (length--) {
|
||||
// Deep compare each member
|
||||
key = _keys[length];
|
||||
if (!has$1(b, key)) return false;
|
||||
todo.push({a: a[key], b: b[key]});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove the first object from the stack of traversed objects.
|
||||
aStack.pop();
|
||||
bStack.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Perform a deep comparison to check if two objects are equal.
|
||||
function isEqual(a, b) {
|
||||
return eq(a, b);
|
||||
}
|
||||
|
||||
// Retrieve all the enumerable property names of an object.
|
||||
function allKeys(obj) {
|
||||
if (!isObject(obj)) return [];
|
||||
@@ -1032,25 +1047,27 @@
|
||||
var isArrayLike = createSizePropertyCheck(getLength);
|
||||
|
||||
// Internal implementation of a recursive `flatten` function.
|
||||
function flatten$1(input, depth, strict, output) {
|
||||
output = output || [];
|
||||
if (!depth && depth !== 0) {
|
||||
depth = Infinity;
|
||||
} else if (depth <= 0) {
|
||||
return output.concat(input);
|
||||
}
|
||||
var idx = output.length;
|
||||
for (var i = 0, length = getLength(input); i < length; i++) {
|
||||
var value = input[i];
|
||||
if (isArrayLike(value) && (isArray(value) || isArguments$1(value))) {
|
||||
function flatten$1(input, depth, strict) {
|
||||
if (!depth && depth !== 0) depth = Infinity;
|
||||
var output = [], idx = 0, i = 0, length = getLength(input) || 0, stack = [];
|
||||
while (true) {
|
||||
if (i >= length) {
|
||||
if (!stack.length) break;
|
||||
var frame = stack.pop();
|
||||
i = frame.i;
|
||||
input = frame.v;
|
||||
length = getLength(input);
|
||||
continue;
|
||||
}
|
||||
var value = input[i++];
|
||||
if (stack.length >= depth) {
|
||||
output[idx++] = value;
|
||||
} else if (isArrayLike(value) && (isArray(value) || isArguments$1(value))) {
|
||||
// Flatten current level of array or arguments object.
|
||||
if (depth > 1) {
|
||||
flatten$1(value, depth - 1, strict, output);
|
||||
idx = output.length;
|
||||
} else {
|
||||
var j = 0, len = value.length;
|
||||
while (j < len) output[idx++] = value[j++];
|
||||
}
|
||||
stack.push({i: i, v: input});
|
||||
i = 0;
|
||||
input = value;
|
||||
length = getLength(input);
|
||||
} else if (!strict) {
|
||||
output[idx++] = value;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
{% if not public %}
|
||||
window.g.isPublicPage = false
|
||||
{% endif %}
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
<!-- scripts from extensions -->
|
||||
|
||||
@@ -1050,6 +1050,34 @@ include('components/lnbits-error.vue') %}
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="authMethods.includes('oidc-auth')"
|
||||
href="/api/v1/auth/oidc"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
class="btn-fixed-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.oidcIcon
|
||||
? g.settings.oidcIcon
|
||||
: utils.url_for('lnbits/static/images/generic-oidc-logo.svg')
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<span
|
||||
v-text="
|
||||
$t('signin_with_custom_org', {
|
||||
custom_org: g.settings.oidcOrg || 'OIDC'
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
@@ -160,6 +160,27 @@
|
||||
min="0"
|
||||
></q-input>
|
||||
</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 v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
|
||||
@@ -192,6 +192,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('oidc-auth')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">OIDC Auth</strong>
|
||||
|
||||
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_discovery_url"
|
||||
:label="$t('auth_oidc_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_id"
|
||||
:label="$t('auth_oidc_ci_label')"
|
||||
:hint="$t('auth_oidc_ci_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_secret"
|
||||
type="password"
|
||||
:label="$t('auth_oidc_cs_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_custom_org"
|
||||
:label="$t('auth_oidc_custom_org_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.oidc_client_custom_icon"
|
||||
:label="$t('auth_oidc_custom_icon_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pa-none">
|
||||
<br />
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block
|
||||
page_container %}
|
||||
{% extends "base.html" %} {% block page_container %}
|
||||
<lnbits-error
|
||||
code="{{ status_code | safe }}"
|
||||
message="{{ message | safe }}"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{% macro window_vars(user) -%}
|
||||
<script>
|
||||
//Needed for Vue to create the app on first load (although called on every page, its only loaded once)
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin]
|
||||
})
|
||||
<script>
|
||||
// deprecated dont use window_vars anymore
|
||||
</script>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -262,7 +262,9 @@
|
||||
<div
|
||||
v-if="
|
||||
'google-auth' in g.settings.authMethods ||
|
||||
'github-auth' in g.settings.authMethods
|
||||
'github-auth' in g.settings.authMethods ||
|
||||
'keycloak-auth' in g.settings.authMethods ||
|
||||
'oidc-auth' in g.settings.authMethods
|
||||
"
|
||||
class="col q-pa-sm text-h6"
|
||||
>
|
||||
@@ -310,6 +312,58 @@
|
||||
<div>GitHub</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
v-if="'keycloak-auth' in g.settings.authMethods"
|
||||
class="col q-pa-sm"
|
||||
>
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/keycloak?user_id=${g.user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.keycloakIcon
|
||||
? g.settings.keycloakIcon
|
||||
: '{{ static_url_for('static', 'images/keycloak-logo.png') }}'
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div
|
||||
v-text="g.settings.keycloakOrg || 'Keycloak'"
|
||||
></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
v-if="'oidc-auth' in g.settings.authMethods"
|
||||
class="col q-pa-sm"
|
||||
>
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/oidc?user_id=${g.user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="
|
||||
g.settings.oidcIcon
|
||||
? g.settings.oidcIcon
|
||||
: '{{ static_url_for('static', 'images/generic-oidc-logo.svg') }}'
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div v-text="g.settings.oidcOrg || 'OIDC'"></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
:value="'LIGHTNING:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
:href="'lightning:' + selectedRelease.paymentRequest"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
@@ -836,7 +836,7 @@
|
||||
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
||||
<lnbits-qrcode
|
||||
:value="
|
||||
'lightning:' +
|
||||
'LIGHTNING:' +
|
||||
selectedExtension.payToEnable.paymentRequest.toUpperCase()
|
||||
"
|
||||
:href="
|
||||
@@ -1247,13 +1247,10 @@
|
||||
<q-dialog v-model="paymentDialog.show" position="top">
|
||||
<q-card class="q-pa-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
|
||||
<lnbits-qrcode
|
||||
:value="paymentDialog.invoice"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
<lnbits-qrcode
|
||||
:value="'LIGHTNING:' + paymentDialog.invoice.toUpperCase()"
|
||||
:href="'lightning:' + paymentDialog.invoice"
|
||||
></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<q-card-actions align="between">
|
||||
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
|
||||
|
||||
@@ -589,23 +589,16 @@
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a
|
||||
<lnbits-qrcode
|
||||
:href="
|
||||
'lightning:' +
|
||||
transactionDetailsDialog.data.bolt11
|
||||
"
|
||||
>
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="
|
||||
'lightning:' +
|
||||
transactionDetailsDialog.data.bolt11.toUpperCase()
|
||||
"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
:value="
|
||||
'LIGHTNING:' +
|
||||
transactionDetailsDialog.data.bolt11.toUpperCase()
|
||||
"
|
||||
></lnbits-qrcode>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@@ -698,25 +691,15 @@
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a
|
||||
<lnbits-qrcode
|
||||
:value="
|
||||
'LIGHTNING:' +
|
||||
props.row.bolt11.toUpperCase()
|
||||
"
|
||||
:href="
|
||||
'lightning:' + props.row.bolt11
|
||||
"
|
||||
>
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl"
|
||||
>
|
||||
<qrcode-vue
|
||||
:value="
|
||||
'lightning:' +
|
||||
props.row.bolt11.toUpperCase()
|
||||
"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
<lnbits-qrcode
|
||||
v-else
|
||||
:href="'lightning:' + receive.paymentReq"
|
||||
:value="'lightning:' + receive.paymentReq"
|
||||
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
|
||||
>
|
||||
</lnbits-qrcode>
|
||||
<div class="text-center">
|
||||
|
||||
@@ -69,7 +69,7 @@ def decrypt_content(
|
||||
1:
|
||||
]
|
||||
# extract iv and content
|
||||
(encrypted_content_b64, iv_b64) = content.split("?iv=")
|
||||
encrypted_content_b64, iv_b64 = content.split("?iv=")
|
||||
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
|
||||
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||
# Decrypt
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"quasar": "2.18.6",
|
||||
"showdown": "^2.1.0",
|
||||
"underscore": "^1.13.7",
|
||||
"underscore": "^1.13.8",
|
||||
"vue": "3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
@@ -1260,9 +1260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1722,9 +1722,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.25",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"quasar": "2.18.6",
|
||||
"showdown": "^2.1.0",
|
||||
"underscore": "^1.13.7",
|
||||
"underscore": "^1.13.8",
|
||||
"vue": "3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.5.2-rc1"
|
||||
version = "1.5.2"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" }
|
||||
readme = "README.md"
|
||||
dependencies = [
|
||||
"bech32==1.2.0",
|
||||
"click==8.3.1",
|
||||
"fastapi==0.116.1",
|
||||
"starlette==0.47.1",
|
||||
"httpx==0.27.2",
|
||||
"jinja2==3.1.6",
|
||||
"lnurl==0.8.3",
|
||||
"pydantic==1.10.26",
|
||||
"pyqrcode==1.2.1",
|
||||
"shortuuid==1.0.13",
|
||||
"sse-starlette==2.3.6",
|
||||
"typing-extensions==4.15.0",
|
||||
"uvicorn==0.40.0",
|
||||
"sqlalchemy==1.4.54",
|
||||
"aiosqlite==0.22.1",
|
||||
"asyncpg==0.31.0",
|
||||
"uvloop==0.22.1",
|
||||
"websockets==15.0.1",
|
||||
"loguru==0.7.3",
|
||||
"grpcio==1.76.0",
|
||||
"protobuf==6.33.2",
|
||||
"pyln-client==25.12",
|
||||
"pywebpush==2.2.0",
|
||||
"slowapi==0.1.9",
|
||||
"websocket-client==1.9.0",
|
||||
"pycryptodomex==3.23.0",
|
||||
"packaging==25.0",
|
||||
"bolt11==2.1.1",
|
||||
"pyjwt==2.10.1",
|
||||
"itsdangerous==2.2.0",
|
||||
"fastapi-sso==0.19.0",
|
||||
"bech32~=1.2.0",
|
||||
"click~=8.3.1",
|
||||
"fastapi~=0.116.1",
|
||||
"starlette~=0.47.1",
|
||||
"httpx~=0.27.2",
|
||||
"jinja2~=3.1.6",
|
||||
"lnurl~=0.10.0",
|
||||
"pydantic~=1.10.26",
|
||||
"pyqrcode~=1.2.1",
|
||||
"shortuuid~=1.0.13",
|
||||
"sse-starlette~=2.3.6",
|
||||
"typing-extensions~=4.15.0",
|
||||
"uvicorn~=0.40.0",
|
||||
"sqlalchemy~=1.4.54",
|
||||
"aiosqlite~=0.22.1",
|
||||
"asyncpg~=0.31.0",
|
||||
"uvloop~=0.22.1",
|
||||
"websockets~=15.0.1",
|
||||
"loguru~=0.7.3",
|
||||
"grpcio~=1.76.0",
|
||||
"protobuf~=6.33.5",
|
||||
"pyln-client~=25.12.0",
|
||||
"pywebpush~=2.2.0",
|
||||
"slowapi~=0.1.9",
|
||||
"websocket-client~=1.9.0",
|
||||
"pycryptodomex~=3.23.0",
|
||||
"packaging~=25.0.0",
|
||||
"bolt11~=2.1.1",
|
||||
"pyjwt~=2.12.0",
|
||||
"itsdangerous~=2.2.0",
|
||||
"fastapi-sso~=0.19.0",
|
||||
# needed for boltz, lnurldevice, watchonly extensions
|
||||
"embit==0.8.0",
|
||||
"embit~=0.8.0",
|
||||
# needed for scheduler extension
|
||||
"python-crontab==3.3.0",
|
||||
"pynostr==0.7.0",
|
||||
"python-multipart==0.0.21",
|
||||
"filetype==1.2.0",
|
||||
"nostr-sdk==0.44.0",
|
||||
"bcrypt==5.0.0",
|
||||
"jsonpath-ng==1.7.0",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"greenlet (>=3.3.0,<4.0.0)",
|
||||
"python-crontab~=3.3.0",
|
||||
"pynostr~=0.7.0",
|
||||
"python-multipart~=0.0.22",
|
||||
"filetype~=1.2.0",
|
||||
"nostr-sdk~=0.44.0",
|
||||
"bcrypt~=5.0.0",
|
||||
"jsonpath-ng~=1.7.0",
|
||||
"pillow~=12.1.0",
|
||||
"python-dotenv~=1.2.1",
|
||||
"greenlet~=3.3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -58,31 +58,31 @@ lnbits = "lnbits.server:main"
|
||||
lnbits-cli = "lnbits.commands:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
|
||||
liquid = ["wallycore==1.5.1"]
|
||||
migration = ["psycopg2-binary==2.9.11"]
|
||||
breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"]
|
||||
liquid = ["wallycore~=1.5.1"]
|
||||
migration = ["psycopg2-binary~=2.9.11"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.12.0,<26.0.0",
|
||||
"mypy==1.17.1",
|
||||
"types-protobuf>=6.32.1.20251210,<7.0.0",
|
||||
"pre-commit>=4.5.1,<5.0.0",
|
||||
"openapi-spec-validator>=0.7.2,<1.0.0",
|
||||
"ruff>=0.14.10,<1.0.0",
|
||||
"types-passlib>=1.7.7.20250602,<2.0.0",
|
||||
"openai>=2.14.0",
|
||||
"json5>=0.13.0,<1.0.0",
|
||||
"asgi-lifespan>=2.1.0,<3.0.0",
|
||||
"anyio>=4.12.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-md>=0.2.0,<0.3.0",
|
||||
"pytest-httpserver>=1.1.3,<2.0.0",
|
||||
"pytest-mock>=3.15.1,<4.0.0",
|
||||
"types-mock>=5.2.0.20250924,<6.0.0",
|
||||
"mock>=5.2.0,<6.0.0",
|
||||
"grpcio-tools>=1.76.0,<2.0.0"
|
||||
"black~=26.3.1",
|
||||
"mypy~=1.17.1",
|
||||
"types-protobuf~=6.32.1.20251210",
|
||||
"pre-commit~=4.5.1",
|
||||
"openapi-spec-validator~=0.7.2",
|
||||
"ruff~=0.14.10",
|
||||
"types-passlib~=1.7.7.20250602",
|
||||
"openai~=2.14.0",
|
||||
"json5~=0.13.0",
|
||||
"asgi-lifespan~=2.1.0",
|
||||
"anyio~=4.12.1",
|
||||
"pytest~=9.0.2",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-md~=0.2.0",
|
||||
"pytest-httpserver~=1.1.3",
|
||||
"pytest-mock~=3.15.1",
|
||||
"types-mock~=5.2.0.20250924",
|
||||
"mock~=5.2.0",
|
||||
"grpcio-tools~=1.76.0"
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
|
||||
@@ -43,6 +43,7 @@ def anyio_backend():
|
||||
@pytest.fixture(scope="session")
|
||||
def settings():
|
||||
# override settings for tests
|
||||
lnbits_settings.auth_https_only = False
|
||||
lnbits_settings.lnbits_admin_extensions = []
|
||||
lnbits_settings.lnbits_data_folder = "./tests/data"
|
||||
lnbits_settings.lnbits_admin_ui = True
|
||||
|
||||
@@ -6,10 +6,6 @@ from pydantic import BaseModel
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
|
||||
|
||||
class FakeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DbTestModel(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_standalone_payment, update_payment
|
||||
@@ -18,7 +19,7 @@ from lnbits.exceptions import PaymentError
|
||||
from lnbits.tasks import create_task, wait_for_paid_invoices
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
from ..helpers import FakeError, is_fake, is_regtest
|
||||
from ..helpers import is_fake, is_regtest
|
||||
from .helpers import (
|
||||
cancel_invoice,
|
||||
get_real_invoice,
|
||||
@@ -138,7 +139,9 @@ async def test_pay_real_invoice_mainnet(
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_from):
|
||||
async def test_create_real_invoice(
|
||||
client, adminkey_headers_from, inkey_headers_from, mocker: MockerFixture
|
||||
):
|
||||
prev_balance = await get_node_balance_sats()
|
||||
create_invoice = CreateInvoice(out=False, amount=1000, memo="test")
|
||||
response = await client.post(
|
||||
@@ -156,34 +159,32 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
|
||||
payment_status = response.json()
|
||||
assert not payment_status["paid"]
|
||||
|
||||
async def on_paid(payment: Payment):
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
|
||||
|
||||
assert payment.payment_hash == invoice["payment_hash"]
|
||||
assert payment.checking_id == invoice["checking_id"]
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
await asyncio.sleep(1)
|
||||
balance = await get_node_balance_sats()
|
||||
fee = abs(payment_status.get("details", {}).get("fee", 0) // 1000)
|
||||
assert balance - prev_balance == create_invoice.amount - fee
|
||||
|
||||
assert payment_status.get("preimage") is not None
|
||||
|
||||
# exit out of infinite loop
|
||||
raise FakeError()
|
||||
|
||||
task = create_task(wait_for_paid_invoices("test_create_invoice", on_paid)())
|
||||
pay_real_invoice(invoice["bolt11"])
|
||||
|
||||
# wait for the task to exit
|
||||
with pytest.raises(FakeError):
|
||||
await task
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert on_paid_mock.call_count == 1
|
||||
payment = on_paid_mock.call_args_list[0][0][0]
|
||||
|
||||
assert payment.payment_hash == invoice["payment_hash"]
|
||||
assert payment.checking_id == invoice["checking_id"]
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
await asyncio.sleep(1)
|
||||
balance = await get_node_balance_sats()
|
||||
fee = abs(payment_status.get("details", {}).get("fee", 0) // 1000)
|
||||
assert balance - prev_balance == create_invoice.amount - fee
|
||||
|
||||
assert payment_status.get("preimage") is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -367,7 +368,7 @@ async def test_pay_hold_invoice_check_pending_and_fail_cancel_payment_task_in_me
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
client, adminkey_headers_from, inkey_headers_from
|
||||
client, adminkey_headers_from, inkey_headers_from, mocker: MockerFixture
|
||||
):
|
||||
"""
|
||||
1. We create a real invoice
|
||||
@@ -391,39 +392,36 @@ async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
payment_status = response.json()
|
||||
assert not payment_status["paid"]
|
||||
|
||||
async def on_paid(payment: Payment):
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
|
||||
|
||||
assert payment.payment_hash == invoice["payment_hash"]
|
||||
assert payment.checking_id == invoice["checking_id"]
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
assert payment
|
||||
|
||||
# set the incoming invoice to pending
|
||||
payment.status = PaymentState.PENDING
|
||||
await update_payment(payment)
|
||||
|
||||
payment_pending = await get_standalone_payment(
|
||||
invoice["payment_hash"], incoming=True
|
||||
)
|
||||
assert payment_pending
|
||||
assert payment_pending.success is False
|
||||
assert payment_pending.failed is False
|
||||
|
||||
# exit out of infinite loop
|
||||
raise FakeError()
|
||||
|
||||
task = create_task(wait_for_paid_invoices("test_create_invoice", on_paid)())
|
||||
pay_real_invoice(invoice["bolt11"])
|
||||
|
||||
with pytest.raises(FakeError):
|
||||
await task
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert on_paid_mock.call_count == 1
|
||||
payment = on_paid_mock.call_args_list[0][0][0]
|
||||
|
||||
assert payment.payment_hash == invoice["payment_hash"]
|
||||
assert payment.checking_id == invoice["checking_id"]
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
# set the incoming invoice to pending
|
||||
payment.status = PaymentState.PENDING
|
||||
await update_payment(payment)
|
||||
|
||||
payment_pending = await get_standalone_payment(
|
||||
invoice["payment_hash"], incoming=True
|
||||
)
|
||||
assert payment_pending
|
||||
assert payment_pending.success is False
|
||||
assert payment_pending.failed is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -2,10 +2,16 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.crud.users import get_user_from_account
|
||||
from lnbits.core.crud.wallets import delete_wallet, get_wallets
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.crud.users import (
|
||||
create_account,
|
||||
delete_account,
|
||||
get_accounts,
|
||||
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
|
||||
@@ -36,3 +42,151 @@ async def test_get_user_from_account_is_wallet_created():
|
||||
assert (
|
||||
len(user.wallets) == 1
|
||||
), "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
|
||||
|
||||
@@ -6,25 +6,21 @@ from tests.helpers import DbTestModel
|
||||
@pytest.fixture(scope="session")
|
||||
async def fetch_page(db):
|
||||
await db.execute("DROP TABLE IF EXISTS test_db_fetch_page")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE test_db_fetch_page (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
INSERT INTO test_db_fetch_page (id, name, value) VALUES
|
||||
('1', 'Alice', 'foo'),
|
||||
('2', 'Bob', 'bar'),
|
||||
('3', 'Carol', 'bar'),
|
||||
('4', 'Dave', 'bar'),
|
||||
('5', 'Dave', 'foo')
|
||||
"""
|
||||
)
|
||||
""")
|
||||
yield
|
||||
await db.execute("DROP TABLE test_db_fetch_page")
|
||||
|
||||
|
||||
@@ -10,16 +10,12 @@ from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
|
||||
from lnbits.core.crud.payments import get_payment, get_payments_paginated
|
||||
from lnbits.core.models import Payment, PaymentState, Wallet
|
||||
from lnbits.core.models import PaymentState, Wallet
|
||||
from lnbits.core.services import create_invoice, create_user_account, pay_invoice
|
||||
from lnbits.core.services.payments import update_wallet_balance
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.tasks import (
|
||||
create_permanent_task,
|
||||
internal_invoice_listener,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.tasks import create_task, wait_for_paid_invoices
|
||||
from lnbits.wallets.base import PaymentResponse
|
||||
from lnbits.wallets.fake import FakeWallet
|
||||
|
||||
@@ -230,13 +226,13 @@ async def test_pay_for_extension(to_wallet: Wallet, settings: Settings):
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_for_internal_payment(to_wallet: Wallet):
|
||||
async def test_notification_for_internal_payment(
|
||||
to_wallet: Wallet, mocker: MockerFixture
|
||||
):
|
||||
test_name = "test_notification_for_internal_payment"
|
||||
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, test_name)
|
||||
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
|
||||
payment = await create_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
amount=123,
|
||||
@@ -248,17 +244,15 @@ async def test_notification_for_internal_payment(to_wallet: Wallet):
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
while True:
|
||||
_payment: Payment = invoice_queue.get_nowait() # raises if queue empty
|
||||
assert _payment
|
||||
if _payment.memo == test_name:
|
||||
assert _payment.status == PaymentState.SUCCESS.value
|
||||
assert _payment.bolt11 == payment.bolt11
|
||||
assert _payment.amount == 123_000
|
||||
updated_payment = await get_payment(_payment.checking_id)
|
||||
assert updated_payment.webhook_status == "404"
|
||||
assert on_paid_mock.call_count == 1
|
||||
_payment = on_paid_mock.call_args_list[0][0][0]
|
||||
|
||||
break # we found our payment, success
|
||||
assert _payment.memo == test_name
|
||||
assert _payment.status == PaymentState.SUCCESS.value
|
||||
assert _payment.bolt11 == payment.bolt11
|
||||
assert _payment.amount == 123_000
|
||||
updated_payment = await get_payment(_payment.checking_id)
|
||||
assert updated_payment.webhook_status == "404"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -40,7 +40,7 @@ def encrypt_content(priv_key, dest_pub_key, content):
|
||||
def decrypt_content(priv_key, source_pub_key, content):
|
||||
p = PublicKey(bytes.fromhex("02" + source_pub_key))
|
||||
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"))
|
||||
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||
|
||||
@@ -136,12 +136,10 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
|
||||
assert os.path.isfile(file), f"{file} does not exist!"
|
||||
|
||||
sqlite_cursor = get_sqlite_cursor(file)
|
||||
tables = sqlite_cursor.execute(
|
||||
"""
|
||||
tables = sqlite_cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name not like 'sqlite?_%' escape '?'
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
|
||||
for table in tables:
|
||||
table_name = table[0]
|
||||
@@ -184,11 +182,9 @@ def build_table_columns(file: str, schema: str, table_name: str):
|
||||
sqlite_columns = sqlite_cursor.execute(
|
||||
f"PRAGMA table_info({table_name})"
|
||||
).fetchall()
|
||||
pg_cursor.execute(
|
||||
f"""
|
||||
pg_cursor.execute(f"""
|
||||
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()
|
||||
|
||||
columns = []
|
||||
|
||||