Compare commits

..
Author SHA1 Message Date
dni ⚡ bc7e3d4dc8 fifi 2026-03-25 11:29:03 +01:00
dni ⚡ 034ba44241 manual testing works 2026-03-25 11:14:53 +01:00
dni ⚡ b0b8ea95f2 2nd stage draft pydantic 2026-03-25 10:47:13 +01:00
dni ⚡ 261350243f stage2 update fastapi 2026-03-25 10:23:35 +01:00
dni ⚡ ed02802ad6 stage one moving to last fastapi version supporting pydantic.v1 2026-03-25 09:39:22 +01:00
121 changed files with 1291 additions and 14817 deletions
+1 -11
View File
@@ -7,20 +7,14 @@
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# If set the user is required to enter this token on the /first_install page
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
@@ -30,10 +24,6 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings ===
-3
View File
@@ -69,10 +69,7 @@ jobs:
--onefile \
--name lnbits \
--hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
+4
View File
@@ -1,5 +1,9 @@
name: LNbits CI
on:
push:
branches:
- main
- dev
pull_request:
-1
View File
@@ -6,7 +6,6 @@ __pycache__
*$py.class
.mypy_cache
.vscode
.codex
*-lock.json
.python-version
-79
View File
@@ -1,79 +0,0 @@
# Feature Spec: [FEAT-XXX] - Short Descriptive Title
**Milestone:** [e.g. MVP Core / Performance & Polish / Extension Framework]
**Priority:** Must-have / Should-have / Nice-to-have
**Spec Owner:** [Your Name / AI Agent Name]
**Status:** Draft → Under Review → Approved → Implemented → Verified
## 1. Purpose & User Story
As a [user type], I want [goal] so that [benefit].
_(One clear sentence. Keep it concise.)_
## 2. Functional Requirements
- [ ] REQ-1: [Clear, testable description]
- [ ] REQ-2: ...
- [ ] REQ-3: ...
_(List what the feature must do. Make each item verifiable.)_
## 3. Non-Functional Requirements
- **Performance:** [e.g. Latency < 800ms at p95, max 5k tokens, etc.]
- **Security / Safety:** [e.g. Input validation, no raw errors to user, etc.]
- **Compatibility:** [e.g. Works with all existing wallet backends, no breaking changes for extensions]
- **UI/UX:** [e.g. Follows existing Quasar/Vue patterns in wallet.js]
- **Other:** [cost, scalability, accessibility, etc.]
## 4. Technical Approach (Optional recommended for complex features)
- Proposed solution: [e.g. Extend existing CRUD in lnbits/core/, new extension, middleware change, etc.]
- Key files to modify: [list expected files]
- New dependencies: [none / specific package + version]
- Migration / Database changes: [yes/no + description]
## 5. Success Criteria & Verification
**Must pass all of these to be accepted:**
- [ ] All functional requirements (REQ-\*) implemented and tested
- [ ] Non-functional requirements met (performance, security, etc.)
- [ ] Relevant tests pass: `make test-unit`, `make test-api`, `make test-regtest` (as applicable)
- [ ] `make check` passes (ruff, mypy, pyright, prettier, checkbundle)
- [ ] Constitution compliance: All changes respect CONSTITUTION.md
- [ ] Backward compatibility: No breakage for existing extensions or wallet backends
- [ ] Documentation updated (if applicable: README, OpenAPI, inline comments)
**Additional Tests / Edge Cases:**
- [ ] Test invalid inputs / error paths
- [ ] Test with FakeWallet and at least one real backend
- [ ] Test with multiple extensions installed
## 6. Safety & Risk Assessment
- Potential risks: [e.g. Payment flow impact, key exposure, extension conflicts]
- Mitigation: [how addressed]
- Security review needed: [yes/no]
## 7. Implementation Notes (for AI / Developer)
- Style to match: Existing code patterns in `lnbits/core/` and `wallet.js`
- Surgical changes only (per AGENTS.md)
- Any known gotchas or dependencies on other features:
## 8. Acceptance Checklist (Sign-off)
- [ ] Spec reviewed and approved by project owner
- [ ] Implementation completed
- [ ] Verification steps passed
- [ ] PR created with link to this spec
- [ ] Constitution & AGENTS.md compliance confirmed
---
**Created:** [Date]
**Last Updated:** [Date]
**Approved By:** [Name / "Approved"]
-122
View File
@@ -1,122 +0,0 @@
# AGENTS.md - Instructions for All AI Coding Agents
This file is the **master instruction manual** for any AI agent (Grok, Claude, Cursor, Aider, etc.) working on LNbits.
## 1. Core Rule (Never Break This)
**You MUST read and strictly follow `CONSTITUTION.md` before doing any planning, coding, refactoring, or suggesting changes.**
- Every single change, feature, extension, or fix **must comply** with the Constitution.
- If you detect a violation (in new code or existing code), you **must** flag it immediately and propose a fix or ask for clarification.
- Constitution > any other instruction (including this file, user prompts, or previous conversations).
## 2. Mandatory Development Workflow
For **any non-trivial task** (new feature, bug fix, refactor, extension change):
1. **Constitution Check** Re-read relevant sections of `CONSTITUTION.md`
2. **Feature Spec Check** If a spec exists in `.specify/`, follow it exactly. If none exists, ask the user for clarification or propose a minimal spec.
3. **Think Step-by-Step** Follow the "Think Before Coding" and "Simplicity First" guidelines below.
4. **Surgical Changes** Only touch what is necessary.
5. **Implement**
6. **Verify** Run relevant tests (`make test-unit`, `make test-api`, etc.), `make check`, and confirm compliance.
7. **Report** Always include a clear summary.
Use the following response format:
```markdown
## Constitution & Spec Compliance
- Relevant Constitution sections checked: [list or quote key rules]
- Feature Spec followed: [yes / no / proposed]
## Assumptions & Plan
- Assumptions: ...
- Plan:
1. ...
2. ...
- Tradeoffs considered: ...
## Changes Made
- Files changed: ...
- Summary of modifications:
## Verification
- [ ] Passes `make check`
- [ ] Relevant tests pass (`make test-xxx`)
- [ ] Complies with Constitution
- [ ] Surgical & minimal (no unrelated changes)
```
## 3. Behavioral Guidelines (Merged & Project-Specific)
**Think Before Coding**
- Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If something is unclear (especially regarding wallets, extensions, or funding sources), stop and ask.
**Simplicity First**
- Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- Respect LNbits' lean core philosophy: new functionality should preferably go into an **extension** unless it truly belongs in core.
**Surgical Changes**
- Touch only what you must. Clean up only your own mess.
- Match existing style (Python: Black + Ruff rules; JS: Prettier).
- Do not "improve" or refactor adjacent code unless explicitly asked.
- When editing, remove only imports/variables/functions made unused **by your changes**.
- Never delete pre-existing dead code unless instructed.
**Goal-Driven Execution**
- Transform tasks into verifiable goals.
- For tests: Write or update tests first when fixing bugs or adding behavior.
- Always consider impact on existing extensions and multiple wallet backends (LND, CLN, Boltz, VoidWallet, etc.).
## 4. LNbits-Specific Rules
- **Extensions First**: Core should remain lean. Prefer implementing new features as extensions unless they are fundamental to wallets, security, or the API.
- **Testing**: Use `FakeWallet` for unit/API tests. Regtest tests for full Lightning flows. Never break existing test targets in the Makefile.
- **Dependencies**: Never add new dependencies without updating `pyproject.toml` and getting approval.
- **Frontend**: JS/Vue code (e.g. `wallet.js`) must follow existing patterns and pass `make checkbundle` when static files are affected.
- **Database / Migrations**: Do not make raw SQL changes. Use existing CRUD/services and migration tooling.
- **Security**: Be extremely cautious with anything touching payments, keys, LNURL, Bolt11, or admin routes.
- **Tools**: Use `uv run` for all commands. Prefer Makefile targets (`make format`, `make check`, `make test-xxx`).
- **Generated Files**: Never modify gRPC files or other generated code.
## 5. Forbidden Behaviors
- Ignoring Constitution rules to "be helpful"
- Large refactors without a spec or explicit request
- Adding features "for future use"
- Breaking backward compatibility for extensions or existing wallet backends
- Committing code that fails `make check` or relevant tests
- Exposing raw errors/stack traces to users
- Using synchronous code in hot async paths without justification
## 6. How to Handle This File + Constitution
When the user gives you a task, start your response with:
> Following LNbits CONSTITUTION.md and AGENTS.md...
Then proceed with the structured format above.
---
**These guidelines are working if:**
- Fewer unnecessary changes appear in diffs
- Clarifying questions come **before** implementation
- All changes respect the lean, extension-first, security-first nature of LNbits
- Tests and `make check` continue to pass
Last Updated: April 2026
-136
View File
@@ -1,136 +0,0 @@
# CONSTITUTION.md
This is the immutable constitution of the LNbits project.
Every feature spec, code change, refactor, extension, or decision by humans or AI agents **must comply** with this document.
Changes to this file require explicit approval from the project owner/maintainers.
## 1. Project Overview
**Project Name:** LNbits
**Core Purpose:** Free and open-source Lightning wallet and accounts system. A lightweight Python server that sits on top of any Lightning funding source, providing safe isolated wallets, a clean REST API, and a powerful extension system for adding features rapidly.
**Target Users:** Individuals, communities, merchants, developers, and enterprises building on Bitcoin/Lightning (self-hosted or as part of larger stacks).
**High-Level Success Criteria:**
- Reliable multi-wallet Lightning accounting with any backend (20+ supported funding sources)
- Secure, extensible via 60+ extensions without bloating core
- High code quality, test coverage, and backward compatibility for extensions
- Production-ready performance and security for real Bitcoin value
**Version:** 1.5.4
## 2. Technology Stack (Strict)
- **Language:** Python >=3.10, <3.13 (strictly enforced via `pyproject.toml`)
- **Framework:** FastAPI + Starlette (backend API)
- **Frontend:** Vue.js + Quasar framework, with bundled static assets
- **Database:** SQLite (aiosqlite) by default, PostgreSQL (asyncpg/psycopg2) supported via `LNBITS_DATABASE_URL`
- **Async Runtime:** uvloop preferred
- **Dependency Management:** uv + pyproject.toml (Hatchling build backend). Use `uv run` for all commands.
- **Wallet Backends:** Abstracted via `lnbits.wallets` support for LND, Core Lightning, Phoenixd, Boltz, Breez SDK, Liquid, VoidWallet fallback, etc. New backends must follow existing abstraction.
- **Other Key Libs:** SQLAlchemy, Pydantic (v1), Loguru, Jinja2, LNURL, Bolt11, etc. (see `pyproject.toml` for pinned versions)
- **Build/Frontend Tools:** npm for bundling (Quasar/Vue), Prettier for JS/CSS (check Makefile targets)
**Forbidden:**
- Adding new top-level dependencies without updating `pyproject.toml` **and** team approval
- Using synchronous blocking calls in async paths (except where explicitly justified)
- Direct database queries outside of CRUD layers or core services
- Modifying generated files (e.g., gRPC files in wallets/boltz_grpc_files or lnd_grpc_files)
## 3. Architecture & Code Organization (Mandatory Rules)
- **Core Principle:** Modular monolith with heavy emphasis on **extensions**. All non-core features must live in extensions (installed via `lnbits/extensions`). Core stays lean.
- **Backend Structure:**
- `lnbits/core/` for core models, CRUD, services, routers, tasks
- `lnbits/wallets/` for funding source abstractions
- `lnbits/extensions/` for installed/upgradeable extensions (do not commit large extensions to core repo)
- `lnbits/static/` for bundled frontend assets (managed via npm bundle)
- **Key Rules:**
- Use dependency injection and FastAPI routers properly
- Extensions register routes/tasks via `register_ext_routes` / `register_ext_tasks`
- Database migrations handled centrally (extension-specific migrations)
- All new endpoints must be under proper versioning/prefixing where applicable
- Frontend: Vue 2/Quasar components in `wallet.js` style (or updated) keep reactive, use LNbits.utils helpers
- No circular imports; respect existing middleware order (e.g., InstalledExtensionMiddleware before ExtensionsRedirectMiddleware)
**Exclusions** (do not lint/format these):
- `lnbits/extensions/`, `lnbits/upgrades/`, generated gRPC files, static/vendor bundles
## 4. Code Quality & Style
- **Formatting & Linting:**
- Python: Black (line-length 88), Ruff (with selected rules: F, E, W, I, A, C, N, UP, RUF, B, S), MyPy (strict where possible), Pyright
- JS/Frontend: Prettier
- Run via Makefile: `make format` and `make check`
- **Type Checking:** MyPy + Pyright enforced on `lnbits/`, `tests/`, `tools/`
- **Testing Requirements:**
- Unit, API, wallet, and regtest tests via pytest (see Makefile targets)
- New core code or critical paths: high coverage expected (`--cov=lnbits`)
- Extensions should include their own tests where possible
- **Error Handling & Logging:** Use Loguru with structured context. Never expose raw stack traces to end users. Graceful fallbacks (e.g., VoidWallet on funding source failure).
- **Pre-commit:** Strongly recommended (`make install-pre-commit-hook`)
- **Bundle Integrity:** Frontend bundles must pass `make checkbundle` before commits affecting static files.
## 5. AI / LLM Usage Standards (if any agents or future AI features are added)
- Any new AI-powered features (e.g., via extensions) must use structured outputs (Pydantic/JSON mode)
- Store prompts/templates versioned in the extension
- Prefer deterministic behavior for financial/security paths (low temperature)
- All AI outputs involving value/money must be validated server-side
- Safety: Never allow untrusted model output to influence payments, wallet balances, or admin actions without guardrails
## 6. Safety, Security & Ethics
- **Critical:** Handle real Bitcoin/Lightning value → security-first mindset
- Per-wallet isolation with separate admin/invoice/read keys
- Rate limiting (SlowAPI) and IP blocking middleware mandatory
- Sanitize all user inputs; validate LNURL, Bolt11, etc.
- PII: Minimal collection; respect privacy (no unnecessary logging of sensitive data)
- Funding source failures: Graceful degradation to VoidWallet + clear logging
- Extensions: Hash-verified installs for vetted extensions; careful with custom extension paths
- Audit logging via AuditMiddleware
- Forbidden: Hard-coded secrets, insecure subprocess calls without review, SQL injection risks (use SQLAlchemy properly)
## 7. Performance & Cost Budgets
- Keep core lightweight extensions handle heavy features
- Async-first (uvloop, asyncpg/aiosqlite)
- Reasonable retry logic for funding source connections (see `check_funding_source`)
- Frontend: Optimized bundles (checkbundle enforced)
- No unnecessary blocking operations in request paths
## 8. Development Workflow (Spec-Driven where possible)
- **All significant changes** should follow Spec-Driven Development:
- Create/update Feature Spec in `.specify/` folder (or equivalent)
- Reference this Constitution in every spec and PR
- Use Makefile targets for format/check/test
- Tests run with `FakeWallet` by default for unit/API; regtest for full flows
- PRs must:
- Pass `make check` and relevant tests
- Include Constitution compliance notes (via AGENTS.md/CLAUDE.md)
- Not break existing extensions or wallet backends
- Branching: Protect main; use feature branches
- Extensions: Develop separately; core repo focuses on framework stability
## 9. Decision Hierarchy (What Takes Precedence)
1. This Constitution
2. Approved Feature Spec / Milestone
3. Existing tests and backward compatibility (especially for extensions and wallet backends)
4. Project maintainers / owner decision
5. Everything else (including helpful AI suggestions)
If conflict: Stop, document the issue, and seek clarification from maintainers.
## 10. Amendment Process
- This Constitution can only be changed with explicit approval from project maintainers.
- All changes must be dated, versioned, and reflected in `AGENTS.md`.
- Minor clarifications can be proposed via PR with justification.
---
**Last Updated:** April 2026 (based on v1.5.4)
**Owner/Maintainers Approval:** LNbits Team
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
-1
View File
@@ -66,7 +66,6 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest
test-migration:
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" />
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools
## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
+1
View File
@@ -14,6 +14,7 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets
-4
View File
@@ -65,7 +65,6 @@ from .middleware import (
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -197,9 +196,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
+3 -1
View File
@@ -711,7 +711,9 @@ async def _call_install_extension(
user_id = user_id or get_super_user()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40
f"{url}/api/v1/extension?usr={user_id}",
json=data.model_dump(),
timeout=40,
)
resp.raise_for_status()
else:
-4
View File
@@ -12,7 +12,6 @@ from .extensions import (
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_accounts_count,
get_user,
get_user_access_control_lists,
get_user_from_account,
@@ -119,13 +117,11 @@ __all__ = [
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_accounts_count",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
-7
View File
@@ -90,13 +90,6 @@ async def get_installed_extensions(
return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
+2 -3
View File
@@ -44,7 +44,7 @@ async def update_admin_settings(
data: EditableSettings, tag: str | None = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
editable_settings.update(data.model_dump(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
@@ -105,8 +105,7 @@ async def get_settings_field(
)
if not row:
return None
value = json.loads(row["value"]) if row["value"] else None
return SettingsField(id=row["id"], value=value, tag=row["tag"])
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
-7
View File
@@ -37,13 +37,6 @@ async def create_account(
return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account)
+2 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, Field
@@ -21,8 +22,7 @@ class AuditEntry(BaseModel):
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
+6 -6
View File
@@ -96,7 +96,7 @@ class ExtensionConfig(BaseModel):
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
return ExtensionConfig.model_validate(config)
class ReleasePaymentInfo(BaseModel):
@@ -304,7 +304,7 @@ class ExtensionRelease(BaseModel):
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
return [GitHubRepoRelease.model_validate(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
@@ -757,7 +757,7 @@ class InstallableExtension(BaseModel):
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
github_repo = GitHubRepo.model_validate(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
@@ -771,15 +771,15 @@ class InstallableExtension(BaseModel):
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
GitHubRepoRelease.model_validate(latest_release),
ExtensionConfig.model_validate(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
return Manifest.model_validate(manifest)
class CreateExtension(BaseModel):
+31 -21
View File
@@ -6,7 +6,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from pydantic import BaseModel, validator
from pydantic import BaseModel, field_validator
from lnbits.helpers import (
camel_to_snake,
@@ -141,7 +141,8 @@ class DataField(BaseModel):
else:
return f"{self.name} {index}"
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Field name is required.")
@@ -149,7 +150,8 @@ class DataField(BaseModel):
raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v
@validator("type")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Owner Data type is required")
@@ -171,7 +173,8 @@ class DataField(BaseModel):
)
return v
@validator("label")
@field_validator("label")
@classmethod
def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -179,7 +182,8 @@ class DataField(BaseModel):
)
return v
@validator("hint")
@field_validator("hint")
@classmethod
def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
@@ -191,8 +195,7 @@ class DataFields(BaseModel):
editable: bool = True
fields: list[DataField] = []
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.normalize()
def normalize(self) -> None:
@@ -210,7 +213,8 @@ class DataFields(BaseModel):
return field
return None
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Data fields name is required")
@@ -223,12 +227,13 @@ class SettingsFields(DataFields):
enabled: bool = False
type: str = "user"
@validator("type")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Settings type is required")
if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
raise ValueError(f"Field Type must be one of: user, admin. Found: {v}")
return v
@@ -265,8 +270,7 @@ class PreviewAction(BaseModel):
is_client_data_preview: bool = False
is_public_page_preview: bool = False
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
if not self.is_preview_mode:
self.is_settings_preview = False
self.is_owner_data_preview = False
@@ -286,8 +290,7 @@ class ExtensionData(BaseModel):
public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction()
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.validate_data()
self.normalize()
@@ -434,7 +437,8 @@ class ExtensionData(BaseModel):
f" Received: {paid_flag_field.type}."
)
@validator("id")
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension ID is required")
@@ -442,13 +446,15 @@ class ExtensionData(BaseModel):
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension name is required")
return v
@validator("stub_version")
@field_validator("stub_version")
@classmethod
def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -456,7 +462,8 @@ class ExtensionData(BaseModel):
)
return v
@validator("short_description")
@field_validator("short_description")
@classmethod
def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -464,7 +471,8 @@ class ExtensionData(BaseModel):
)
return v
@validator("description")
@field_validator("description")
@classmethod
def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -472,13 +480,15 @@ class ExtensionData(BaseModel):
)
return v
@validator("owner_data")
@field_validator("owner_data")
@classmethod
def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one owner data field is required")
return v
@validator("client_data")
@field_validator("client_data")
@classmethod
def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one client data field is required")
+2 -2
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
lnurl: Lnurl | None = None
amount: int
comment: str | None = None
unit: str | None = None
@@ -14,7 +14,7 @@ class CreateLnurlPayment(BaseModel):
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
lnurl_w: Lnurl | LnAddress
class LnurlScan(BaseModel):
+14 -9
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from typing import Any, Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator
from lnbits.db import FilterModel
from lnbits.fiat.base import (
@@ -62,7 +62,10 @@ class Payment(BaseModel):
amount: int
fee: int
bolt11: str
payment_request: str | None = Field(default=None, no_database=True)
payment_request: str | None = Field(
default=None,
json_schema_extra={"no_database": True},
)
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
@@ -78,8 +81,7 @@ class Payment(BaseModel):
labels: list[str] = []
extra: dict = {}
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"]
else:
@@ -250,13 +252,14 @@ class CreateInvoice(BaseModel):
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
@field_validator("payment_hash")
@classmethod
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@field_validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
@@ -279,7 +282,8 @@ class SettleInvoice(BaseModel):
max_length=64,
)
@validator("preimage")
@field_validator("preimage")
@classmethod
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
@@ -293,7 +297,8 @@ class CancelInvoice(BaseModel):
max_length=64,
)
@validator("payment_hash")
@field_validator("payment_hash")
@classmethod
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+2 -4
View File
@@ -1,6 +1,4 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from pydantic import BaseModel
class TinyURL(BaseModel):
@@ -8,4 +6,4 @@ class TinyURL(BaseModel):
url: str
endless: bool
wallet: str
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
time: float
+16 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
@@ -38,11 +39,9 @@ class WalletInviteRequest(BaseModel):
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
name: str = Field(pattern=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
class UserExtra(BaseModel):
@@ -193,12 +192,20 @@ class Account(AccountId):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
is_super_user: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
is_admin: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
fiat_providers: list[str] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
+8 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
@@ -126,14 +127,16 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
balance_msat: int = Field(default=0, json_schema_extra={"no_database": True})
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
stored_paylinks: StoredPayLinks = Field(default_factory=StoredPayLinks)
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
share_permissions: list[WalletPermission] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self._validate_data()
def mirror_shared_wallet(
-12
View File
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
delete_installed_extension,
get_db_version,
get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
@@ -39,8 +38,6 @@ async def install_extension(
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download:
await ext_info.download_archive()
@@ -66,15 +63,6 @@ async def install_extension(
return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
+3 -3
View File
@@ -97,7 +97,7 @@ def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path)
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump(
data.dict(),
data.model_dump(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4,
)
@@ -133,7 +133,7 @@ async def _get_extension_stub_release(
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.dict(), indent=4))
f.write(json.dumps(release.model_dump(), indent=4))
return release
@@ -290,7 +290,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
{
"extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action,
**data.public_page.action_fields.dict(),
**data.public_page.action_fields.model_dump(),
"cancel_comment": remove_line_marker,
},
)
+1 -32
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -509,8 +509,6 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
@@ -821,8 +819,6 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
@@ -1072,30 +1068,3 @@ async def _send_payment_notification_in_background(
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
+2 -1
View File
@@ -45,10 +45,11 @@ def dict_to_settings(sets_dict: dict) -> UpdateSettings:
def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict)
settings_keys = settings.model_dump().keys()
for key in sets_dict.keys():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
if key not in settings_keys:
continue
try:
value = getattr(editable_settings, key)
+3 -21
View File
@@ -23,7 +23,6 @@ from ..crud import (
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_accounts_count,
get_super_settings,
get_user_extensions,
get_user_from_account,
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None,
) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account:
account.validate_fields()
if account.username and await get_account_by_username(
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account:
account.validate_fields()
@@ -175,7 +163,7 @@ async def check_admin_settings():
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
update_cached_settings(settings_db.model_dump())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
@@ -185,12 +173,6 @@ async def check_admin_settings():
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
if settings.has_first_install_token_changed():
logger.warning("First install token is changed. Resetting admin settings.")
new_settings = await init_admin_settings()
settings.super_user = new_settings.super_user
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser."
@@ -210,8 +192,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
editable_settings = EditableSettings.from_dict(settings.model_dump())
return await create_admin_settings(account.id, editable_settings.model_dump())
async def check_register_activation_settings(data: RegisterUser):
+2 -2
View File
@@ -87,7 +87,7 @@ async def api_update_settings(
admin_settings = await get_admin_settings(account.is_super_user)
if not admin_settings:
raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict())
update_cached_settings(admin_settings.model_dump())
core_app_extra.register_new_ratelimiter()
return {"status": "Success"}
@@ -99,7 +99,7 @@ async def api_update_settings(
async def api_update_settings_partial(
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data})
updatable_settings = dict_to_settings({**settings.model_dump(), **data})
return await api_update_settings(updatable_settings, account)
+3 -11
View File
@@ -12,7 +12,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.crud.users import (
get_user_access_control_lists,
update_user_access_control_list,
@@ -549,13 +548,6 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
account.hash_password(data.password)
await update_account(account)
settings.first_install = False
# only confrm it after the super user has been successfully updated
if settings.first_install_token:
settings.first_install_token_confirmed = data.first_install_token
await set_settings_field(
"first_install_token_confirmed", data.first_install_token
)
return _auth_success_response(account.username, account.id, account.email)
@@ -595,7 +587,7 @@ def _auth_success_response(
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie(
@@ -625,7 +617,7 @@ def _auth_api_token_response(
sub=username, api_token_id=api_token_id, auth_time=int(time())
)
return create_access_token(
data=payload.dict(), token_expire_minutes=token_expire_minutes
data=payload.model_dump(), token_expire_minutes=token_expire_minutes
)
@@ -633,7 +625,7 @@ def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResp
payload = AccessTokenPayload(
usr=user_id, sub="", email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path)
response.set_cookie(
+1 -31
View File
@@ -14,8 +14,6 @@ from lnbits.core.services.fiat_providers import (
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.paypal import PayPalWallet
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.settings import settings
@@ -188,11 +186,7 @@ async def handle_paypal_event(event: dict):
resource = event.get("resource", {})
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
if event_type == "CHECKOUT.ORDER.APPROVED":
await _handle_paypal_checkout_order_approved(resource)
return
if event_type == "PAYMENT.CAPTURE.COMPLETED":
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
@@ -211,30 +205,6 @@ async def handle_paypal_event(event: dict):
logger.warning(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_checkout_order_approved(resource: dict):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal approved event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
fiat_provider = await get_fiat_provider("paypal")
if not isinstance(fiat_provider, PayPalWallet):
logger.warning("PayPal provider unavailable for approved order capture.")
return
capture_status = await fiat_provider.capture_order(
payment.extra.get("fiat_checking_id") or payment.checking_id
)
if capture_status.failed:
logger.warning(f"PayPal order capture failed for hash: '{payment_hash}'.")
return
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency") or "").upper()
+4 -7
View File
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, (AssertionError, ValueError))
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException(
status_code=(
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc
@@ -641,7 +637,8 @@ async def create_extension_review(
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
settings.lnbits_extensions_reviews_url + "/reviews",
json=data.model_dump(),
)
resp.raise_for_status()
payment_request = resp.json()
+125 -39
View File
@@ -8,10 +8,20 @@ import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Generic, Literal, TypeVar, get_origin
from types import UnionType
from typing import (
Any,
ClassVar,
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
@@ -20,6 +30,7 @@ from lnbits.settings import settings
POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
PYDANTIC_MODEL_TYPES = (BaseModel,)
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
@@ -28,6 +39,72 @@ sqlite_formats = {
"month": "%Y-%m-01 00:00:00",
}
def _is_pydantic_model_class(model: Any) -> bool:
return isinstance(model, type) and any(
issubclass(model, model_type) for model_type in PYDANTIC_MODEL_TYPES
)
def _issubclass(candidate: Any, parent: Any) -> bool:
return isinstance(candidate, type) and issubclass(candidate, parent)
def _strip_optional(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin not in {Union, UnionType}:
return annotation
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
return _strip_optional(args[0])
return annotation
def _model_fields(model: type[Any]) -> dict[str, Any]:
fields = getattr(model, "model_fields", None)
if fields is not None:
return fields
return getattr(model, "__fields__", {})
def _field_annotation(field: Any) -> Any:
annotation = getattr(field, "annotation", None)
if annotation is None:
annotation = getattr(field, "outer_type_", Any)
return _strip_optional(annotation)
def _field_inner_type(field: Any) -> Any:
type_ = getattr(field, "type_", None)
if type_ is not None:
return _strip_optional(type_)
annotation = _field_annotation(field)
origin = get_origin(annotation)
if origin in {list, set, tuple, dict}:
args = get_args(annotation)
return _strip_optional(args[0]) if args else Any
return annotation
def _field_extra(field: Any) -> dict[str, Any]:
field_info = getattr(field, "field_info", None)
if field_info is not None:
return getattr(field_info, "extra", {})
return getattr(field, "json_schema_extra", None) or {}
def _model_dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump()
def _validate_model(model: type[Any], values: dict[str, Any]) -> Any:
return model.model_validate(values)
if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"):
@@ -447,8 +524,8 @@ class Operator(Enum):
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: list[str] | None = None
__search_fields__: ClassVar[list[str]] = []
__sort_fields__: ClassVar[list[str] | None] = None
T = TypeVar("T")
@@ -462,10 +539,12 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
model_config = ConfigDict(arbitrary_types_allowed=True)
table_name: str | None = None
field: str
op: Operator = Operator.EQ
model: type[TFilterModel] | None
model: type[TFilterModel] | None = Field(default=None, exclude=True)
values: dict | None = None
@classmethod
@@ -487,16 +566,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
field = key
op = Operator("eq")
if field in model.__fields__:
compare_field = model.__fields__[field]
model_fields = _model_fields(model)
if field in model_fields:
compare_field = model_fields[field]
values: dict = {}
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
validated = TypeAdapter(
_field_annotation(compare_field)
).validate_python(raw_value)
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -508,7 +588,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
prefix = f"{self.table_name}." if self.table_name else ""
stmt = []
for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime:
if (
self.model
and _field_inner_type(_model_fields(self.model)[self.field]) == datetime
):
placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
@@ -534,7 +617,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
the values can be validated. Otherwise, make sure to validate the inputs manually.
"""
filters: list[Filter[TFilterModel]] = []
model_config = ConfigDict(arbitrary_types_allowed=True)
filters: list[Filter[TFilterModel]] = Field(default_factory=list)
search: str | None = None
offset: int | None = None
@@ -542,27 +627,27 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None
model: type[TFilterModel] | None = Field(default=None, exclude=True)
table_name: str | None = None
table_name: str | None = Field(default=None, exclude=True)
@root_validator(pre=True)
def validate_sortby(cls, values):
@model_validator(mode="before")
@classmethod
def validate_sortby(cls, values: Any):
if not isinstance(values, dict):
return values
sortby = values.get("sortby")
model = values.get("model")
if sortby and model:
model = values["model"]
# if no sort fields are specified explicitly all fields are allowed
allowed = model.__sort_fields__ or model.__fields__
allowed = model.__sort_fields__ or _model_fields(model).keys()
if sortby not in allowed:
raise ValueError("Invalid sort field")
return values
def pagination(self) -> str:
stmt = ""
if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
@@ -669,10 +754,12 @@ def model_to_dict(model: BaseModel) -> dict:
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if model.__fields__[key].field_info.extra.get("no_database", False):
model_fields = _model_fields(type(model))
for key, value in _model_dump(model).items():
field = model_fields[key]
type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if _field_extra(field).get("no_database", False):
continue
if isinstance(value, datetime):
if DB_TYPE == SQLITE:
@@ -683,7 +770,7 @@ def model_to_dict(model: BaseModel) -> dict:
_dict[key] = value.replace(tzinfo=None)
continue
if (
type(type_) is type(BaseModel)
_is_pydantic_model_class(type_)
or type_ is dict
or get_origin(outertype_) is list
):
@@ -707,51 +794,50 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
:param model: Pydantic model
"""
_dict: dict = {}
model_fields = _model_fields(model)
for key, value in _row.items():
if value is None:
continue
if key not in model.__fields__:
if key not in model_fields:
# Somethimes an SQL JOIN will create additional column
continue
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
field = model_fields[key]
type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if _is_pydantic_model_class(type_) else v
for v in _items
]
continue
if issubclass(type_, bool):
if _issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if _issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
if _is_pydantic_model_class(type_):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
if (type_ is dict or get_origin(outertype_) is dict) and value:
_dict[key] = _safe_load_json(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
return _validate_model(model, _dict)
def _safe_load_json(value: str) -> dict:
+12 -33
View File
@@ -6,7 +6,7 @@ from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
@@ -28,8 +28,7 @@ FiatMethod = Literal["checkout", "subscription"]
class PayPalCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
success_url: str | None = None
cancel_url: str | None = None
@@ -37,16 +36,14 @@ class PayPalCheckoutOptions(BaseModel):
class PayPalSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
checking_id: str | None = None
payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None
@@ -169,7 +166,7 @@ class PayPalWallet(FiatProvider):
return FiatInvoiceResponse(
ok=True,
checking_id=order_id,
checking_id=f"fiat_paypal_{order_id}",
payment_request=approval_url,
)
except Exception as exc:
@@ -285,25 +282,6 @@ class PayPalWallet(FiatProvider):
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("PayPal does not support outgoing payments.")
async def capture_order(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
paypal_id = self._normalize_paypal_id(checking_id)
if paypal_id.startswith("subscription_"):
logger.warning("PayPal subscriptions do not support order capture.")
return FiatPaymentPendingStatus()
r = await self.client.post(
f"/v2/checkout/orders/{paypal_id}/capture",
json={},
headers=self._auth_headers(),
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.warning(f"Error capturing PayPal order '{checking_id}': {exc}")
return await self.get_invoice_status(checking_id)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"PayPal does not support paid invoices stream. Use webhooks instead."
@@ -315,7 +293,7 @@ class PayPalWallet(FiatProvider):
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status == "COMPLETED":
if status in ["COMPLETED", "APPROVED"]:
return FiatPaymentSuccessStatus()
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
@@ -330,10 +308,11 @@ class PayPalWallet(FiatProvider):
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
normalized = checking_id
while normalized.startswith("fiat_paypal_"):
normalized = normalized.replace("fiat_paypal_", "", 1)
return normalized
return (
checking_id.replace("fiat_paypal_", "", 1)
if checking_id.startswith("fiat_paypal_")
else checking_id
)
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
@@ -357,7 +336,7 @@ class PayPalWallet(FiatProvider):
self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None:
try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
return PayPalCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}")
return None
+10 -11
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
@@ -30,8 +30,7 @@ FiatMethod = Literal["checkout", "terminal", "subscription"]
class StripeTerminalOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
@@ -39,8 +38,7 @@ class StripeTerminalOptions(BaseModel):
class StripeCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
@@ -48,16 +46,14 @@ class StripeCheckoutOptions(BaseModel):
class StripeSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
checking_id: str | None = None
payment_request: str | None = None
class StripeCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
@@ -171,7 +167,10 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"),
]
subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
subscription_data = {
**payment_options.model_dump(),
"alan_action": "subscription",
}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata(
@@ -494,7 +493,7 @@ class StripeWallet(FiatProvider):
self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None:
try:
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
return StripeCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}")
return None
+11 -9
View File
@@ -12,7 +12,6 @@ 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.settings import settings
@@ -71,7 +70,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
# used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
t.env.globals["SETTINGS"] = settings.to_public().model_dump(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets:
@@ -127,23 +126,26 @@ def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False
:param keep_optional: If false, all parameters will be optional,
otherwise inferred from model
"""
fields = list(model.__fields__.values())
schema = model.model_json_schema()
properties = schema.get("properties", {})
required = set(schema.get("required", []))
params = []
for field in fields:
schema, _, _ = field_schema(field, model_name_map={})
for field_name, field in model.model_fields.items():
field_key = field.alias or field_name
field_schema = properties.get(field_key, {})
description = "Supports Filtering"
if (
hasattr(model, "__search_fields__")
and field.name in model.__search_fields__
and field_name in model.__search_fields__
):
description += ". Supports Search"
parameter = {
"name": field.alias,
"name": field_key,
"in": "query",
"required": field.required if keep_optional else False,
"schema": schema,
"required": field_key in required if keep_optional else False,
"schema": field_schema,
"description": description,
}
params.append(parameter)
-14
View File
@@ -7,7 +7,6 @@ from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+1 -1
View File
@@ -364,7 +364,7 @@ class LndRestNode(Node):
fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels")
return NodeInfoResponse(
**public.dict(),
**public.model_dump(),
onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"],
+118 -45
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import importlib
import importlib.metadata
import inspect
import json
import os
import re
@@ -11,11 +10,12 @@ from enum import Enum
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from typing import Any, get_args, get_origin
from uuid import uuid4
from dotenv import dotenv_values
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
def list_parse_fallback(v: str):
@@ -29,6 +29,89 @@ def list_parse_fallback(v: str):
return []
def _remove_env_names(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
def _iter_validation_aliases(validation_alias: Any) -> list[str]:
if isinstance(validation_alias, str):
return [validation_alias]
choices = getattr(validation_alias, "choices", None)
if not choices:
return []
return [choice for choice in choices if isinstance(choice, str)]
def _annotation_has_origin(annotation: Any, origins: tuple[type, ...]) -> bool:
origin = get_origin(annotation)
if origin in origins:
return True
return any(_annotation_has_origin(arg, origins) for arg in get_args(annotation))
def _parse_settings_value(value: Any, annotation: Any) -> Any:
if not isinstance(value, str):
return value
stripped_value = value.strip()
if stripped_value.startswith("[") or stripped_value.startswith("{"):
return json.loads(stripped_value)
if _annotation_has_origin(annotation, (list, set, tuple)):
return list_parse_fallback(value)
return value
class LNbitsBaseSettings(BaseModel):
def __init__(self, **data):
super().__init__(**{**self._settings_data(), **data})
@classmethod
def _settings_data(cls) -> dict[str, Any]:
env_file = cls.model_config.get("env_file")
env_file_encoding = cls.model_config.get("env_file_encoding")
case_sensitive = bool(cls.model_config.get("case_sensitive", False))
raw_values: dict[str, Any] = {}
if env_file:
dotenv_items = dotenv_values(env_file, encoding=env_file_encoding)
raw_values.update(
{key: value for key, value in dotenv_items.items() if value is not None}
)
raw_values.update(os.environ)
lookup_values = (
raw_values
if case_sensitive
else {str(key).lower(): value for key, value in raw_values.items()}
)
settings_values: dict[str, Any] = {}
for field_name, field in cls.model_fields.items():
lookup_keys = _iter_validation_aliases(field.validation_alias)
if field.alias and field.alias != field_name:
lookup_keys.append(field.alias)
lookup_keys.append(field_name)
for key in lookup_keys:
lookup_key = key if case_sensitive else key.lower()
if lookup_key not in lookup_values:
continue
settings_values[field_name] = _parse_settings_value(
lookup_values[lookup_key], field.annotation
)
break
return settings_values
class LNbitsSettings(BaseModel):
@classmethod
def validate_list(cls, val):
@@ -284,7 +367,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -324,8 +407,7 @@ class AssetSettings(LNbitsSettings):
"heif",
"heics",
"text/plain",
"text/json",
"text/xml",
"text/jsontext/xml",
"application/json",
"application/pdf",
]
@@ -448,7 +530,6 @@ class SecuritySettings(LNbitsSettings):
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
first_install_token_confirmed: str | None = Field(default=None)
def is_wallet_max_balance_exceeded(self, amount):
return (
@@ -589,8 +670,6 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings):
@@ -656,9 +735,9 @@ class BoltzFundingSource(LNbitsSettings):
class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
default="https://api.strike.me/v1", validation_alias="STRIKE_API_ENDPOINT"
)
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
strike_api_key: str | None = Field(default=None, validation_alias="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel):
@@ -975,12 +1054,12 @@ class EditableSettings(
KeycloakAuthSettings,
OidcAuthSettings,
):
@validator(
@field_validator(
"lnbits_admin_users",
"lnbits_allowed_users",
"lnbits_theme_options",
"lnbits_admin_extensions",
pre=True,
mode="before",
)
@classmethod
def validate_editable_settings(cls, val):
@@ -988,29 +1067,29 @@ class EditableSettings(
@classmethod
def from_dict(cls, d: dict):
return cls(
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
)
return cls(**{k: v for k, v in d.items() if k in cls.model_fields})
# fixes openapi.json validation, remove field env_names
class Config:
@staticmethod
def schema_extra(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
# Fixes openapi.json validation by removing the v1-only env_names metadata.
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra=_remove_env_names,
)
class UpdateSettings(EditableSettings):
class Config:
extra = Extra.forbid
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
json_schema_extra=_remove_env_names,
)
class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# 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)
@@ -1029,20 +1108,11 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
def has_first_install_token_changed(self) -> bool:
if not self.first_install_token:
return False
if not settings.first_install_token_confirmed:
return False
return self.first_install_token != settings.first_install_token_confirmed
def check_auth_secret_key(self):
if self.auth_secret_key:
return
@@ -1123,7 +1193,7 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
return [f for f in cls.model_fields if not f.startswith("_")]
class ReadOnlySettings(
@@ -1138,9 +1208,9 @@ class ReadOnlySettings(
def lnbits_extensions_upgrade_path(self) -> str:
return str(Path(self.lnbits_data_folder, "upgrades"))
@validator(
@field_validator(
"lnbits_allowed_funding_sources",
pre=True,
mode="before",
)
@classmethod
def validate_readonly_settings(cls, val):
@@ -1148,15 +1218,18 @@ class ReadOnlySettings(
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
return [f for f in cls.model_fields if not f.startswith("_")]
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
class Settings(
EditableSettings, ReadOnlySettings, TransientSettings, LNbitsBaseSettings
):
model_config = ConfigDict(
populate_by_name=True,
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
def is_user_allowed(self, user_id: str) -> bool:
return (
@@ -1361,7 +1434,7 @@ if not settings.user_agent:
# printing environment variable for debugging
if not settings.lnbits_admin_ui:
logger.debug("Environment Settings:")
for key, value in settings.dict(exclude_none=True).items():
for key, value in settings.model_dump(exclude_none=True).items():
logger.debug(f"{key}: {value}")
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -275,8 +275,6 @@ window.localisation.en = {
requires_server_restart:
'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -59,7 +59,7 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments')
}
newWallet.url = `/wallet?&wal=${data.id}`
newWallet.storedPaylinks = data.stored_paylinks.links
newWallet.storedPaylinks = data.stored_paylinks?.links
return newWallet
}
}
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd',
{
phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key',
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
phoenixd_api_password: 'Key'
}
],
[
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
]
}
},
methods: {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
methods: {}
})
@@ -165,26 +165,5 @@ window.app.component('lnbits-qrcode', {
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
this.$refs.qrCode.$el.setAttribute('width', '100%')
this.$refs.qrCode.$el.removeAttribute('height')
},
computed: {
optimizedValue() {
const separatorIndex = this.value.indexOf(':')
const type =
separatorIndex === -1 ? '' : this.value.substring(0, separatorIndex)
const value =
separatorIndex === -1
? this.value
: this.value.substring(separatorIndex + 1)
if (this.utils.isValidBech32(value)) {
const normalizedValue = value.toUpperCase()
if (type) {
return `${type.toUpperCase()}:${normalizedValue}`
}
return normalizedValue
}
return this.value
}
}
})
+8 -33
View File
@@ -545,56 +545,31 @@ window.PageAccount = {
if (file) {
this.uploadAsset(file)
}
e.target.value = null
},
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
async uploadAsset(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
await LNbits.api.request(
'POST',
`/api/v1/assets?public_asset=${isPublic}`,
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
if (notifySuccess) {
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
}
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
await this.getUserAssets()
return data
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?')
-40
View File
@@ -160,46 +160,6 @@ window._lnbitsUtils = {
return null
}
},
isValidBech32(value) {
if (typeof value !== 'string') {
return false
}
const candidate = value.trim()
if (
!candidate ||
(candidate !== candidate.toLowerCase() &&
candidate !== candidate.toUpperCase())
) {
return false
}
const normalized = candidate.toLowerCase()
const splitPosition = normalized.lastIndexOf('1')
if (splitPosition <= 0) {
return false
}
const humanReadablePart = normalized.substring(0, splitPosition)
const data = normalized.substring(splitPosition + 1)
if (data.length < 6) {
return false
}
if (
typeof bech32ToFiveBitArray !== 'function' ||
typeof verify_checksum !== 'function'
) {
return false
}
const words = bech32ToFiveBitArray(data)
if (words.some(word => word < 0)) {
return false
}
return verify_checksum(humanReadablePart, words)
},
async notifyApiError(error) {
if (!error.response) {
return console.error(error)
+153 -294
View File
File diff suppressed because it is too large Load Diff
+35 -11
View File
@@ -5,8 +5,12 @@ from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import update_invoice_callback
from lnbits.core.crud import (
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -112,10 +116,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id, is_internal=True)
async def invoice_listener() -> None:
@@ -128,10 +129,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id)
def wait_for_paid_invoices(
@@ -167,7 +165,33 @@ def run_interval(
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
from lnbits.core.services.payments import check_payment_status
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+29 -29
View File
@@ -75,35 +75,7 @@
</p>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<div class="row q-col-gutter-md q-mt-lg">
<div class="row q-col-gutter-md">
<div class="col-12">
<h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -210,6 +182,34 @@
></q-input>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
@@ -52,7 +52,6 @@
:label="prop.label"
:hint="prop.hint"
:value="prop.value"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@@ -74,15 +73,6 @@
></q-btn>
</q-input>
</div>
<p
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
class="col-12 q-my-md"
>
<span>
<q-icon name="warning" color="orange" size="xs"></q-icon>
<span v-text="$t('phoenixd_warning')"></span>
</span>
</p>
</div>
<q-expansion-item
v-if="
@@ -251,27 +251,10 @@
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.adminBackgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
</q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
@@ -12,7 +12,7 @@
>
<qrcode-vue
ref="qrCode"
:value="optimizedValue"
:value="value"
:margin="margin"
:size="size"
level="Q"
+68 -134
View File
@@ -492,28 +492,10 @@
siteCustomisationChanged({bgimageChoice: $event})
"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip
><span v-text="$t('background_image')"></span
></q-tooltip>
</q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-mb-md">
@@ -1126,130 +1108,82 @@
<q-separator></q-separator>
<q-card-section>
<div
class="row items-center no-wrap q-col-gutter-sm"
<q-btn-dropdown
color="grey"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
>
<div class="col">
<q-btn-dropdown
color="grey"
dense
outline
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
<q-list>
<q-item
clickable
v-close-popup
@click="copyAssetLinkToClipboard(props.row)"
>
<q-list>
<q-item
clickable
v-close-popup
@click="
copyAssetLinkToClipboard(props.row)
"
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="
toggleAssetPublicAccess(props.row)
<q-item
clickable
v-close-popup
@click="toggleAssetPublicAccess(props.row)"
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset public</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-card-section>
</q-card>
</div>
+1 -5
View File
@@ -15,11 +15,7 @@ from lnbits.settings import settings
def log_server_info():
logger.info("LNbits Info")
if settings.first_install:
if settings.has_first_install_token_changed():
logger.success("This is a first install token reset.")
else:
logger.success("This is a fresh install of LNbits.")
logger.success("This is a fresh install of LNbits.")
if settings.first_install_token:
logger.success(
f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. "
+2 -52
View File
@@ -4,11 +4,9 @@ import hashlib
import json
import urllib.parse
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
import httpx
from embit.bip39 import mnemonic_is_valid
from httpx import RequestError, TimeoutException
from loguru import logger
from websockets import connect
@@ -63,8 +61,6 @@ class PhoenixdWallet(Wallet):
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
self._seed_mnemonic_to_persist: str | None = None
self._load_mnemonic_from_seed_file()
async def cleanup(self):
try:
@@ -73,7 +69,6 @@ class PhoenixdWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
await self._persist_loaded_mnemonic()
try:
r = await self.client.get("/getinfo", timeout=10)
r.raise_for_status()
@@ -106,6 +101,7 @@ class PhoenixdWallet(Wallet):
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
try:
msats_amount = amount
data: dict[str, Any] = {
@@ -313,7 +309,7 @@ class PhoenixdWallet(Wallet):
and message_json.get("type") == "payment_received"
):
logger.info(
f"payment-received: {message_json['paymentHash']}"
f'payment-received: {message_json["paymentHash"]}'
)
yield message_json["paymentHash"]
@@ -323,49 +319,3 @@ class PhoenixdWallet(Wallet):
"retrying in 5 seconds"
)
await asyncio.sleep(5)
def _load_mnemonic_from_seed_file(self):
data_dir = settings.phoenixd_data_dir
if not data_dir:
return
seed_path = Path(data_dir).expanduser() / "seed.dat"
if not seed_path.is_file():
return
try:
mnemonic = seed_path.read_text(encoding="utf-8").strip()
if mnemonic == settings.phoenixd_mnemonic:
return
except OSError as exc:
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
return
if not mnemonic:
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
return
if not mnemonic_is_valid(mnemonic):
logger.warning(
f"Phoenixd seed file '{seed_path}' does not contain a valid "
"BIP39 mnemonic."
)
return
settings.phoenixd_mnemonic = mnemonic
self._seed_mnemonic_to_persist = mnemonic
async def _persist_loaded_mnemonic(self):
if not self._seed_mnemonic_to_persist:
return
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
try:
from lnbits.core.crud.settings import set_settings_field
await set_settings_field(
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
)
self._seed_mnemonic_to_persist = None
except Exception as exc:
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
+11 -15
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.13.5",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
@@ -734,14 +734,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@@ -1498,9 +1498,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1555,13 +1555,9 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pyright": {
"version": "1.1.289",
+1 -1
View File
@@ -21,7 +21,7 @@
"terser": "^5.44.1"
},
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.13.5",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
Generated
+179 -132
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -199,6 +199,18 @@ files = [
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "anyio"
version = "4.12.1"
@@ -2259,14 +2271,14 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "lnurl"
version = "0.10.0"
version = "2.0.0"
description = "LNURL implementation for Python."
optional = false
python-versions = "<3.13,>=3.10"
groups = ["main"]
files = [
{file = "lnurl-0.10.0-py3-none-any.whl", hash = "sha256:0d43561b4370cecec19c8f1044fc840f0199fc2698bc83cd8782e8264bfde8f4"},
{file = "lnurl-0.10.0.tar.gz", hash = "sha256:55ba0a7e810bcdfc7410c682f91c76df4446ff24e83d6bf931a7c759e2e0d5cc"},
{file = "lnurl-2.0.0-py3-none-any.whl", hash = "sha256:e355f7433aa5ba31557ffbfd042978e8e6f9df90aaae907ebeb613f06ff4f9f4"},
{file = "lnurl-2.0.0.tar.gz", hash = "sha256:4ac17e289eb3818f217b84b41d455f27d0161b300560f6bddd345b923b5ba82d"},
]
[package.dependencies]
@@ -2276,7 +2288,7 @@ bolt11 = "*"
coincurve = ">=20.0.0"
httpx = "*"
pycryptodomex = ">=3.21.0"
pydantic = ">=1.10.0,<2.0.0"
pydantic = ">=2.10.0,<3.0.0"
[[package]]
name = "loguru"
@@ -3320,58 +3332,160 @@ files = [
[[package]]
name = "pydantic"
version = "1.10.26"
description = "Data validation and settings management using python type hints"
version = "2.12.5"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546"},
{file = "pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572"},
{file = "pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464"},
{file = "pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53"},
{file = "pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77"},
{file = "pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f"},
{file = "pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5"},
{file = "pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f"},
{file = "pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb"},
{file = "pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b"},
{file = "pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba"},
{file = "pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62"},
{file = "pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847"},
{file = "pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37"},
{file = "pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a"},
{file = "pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962"},
{file = "pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc5c91a3b3106caf07ac6735ec6efad8ba37b860b9eb569923386debe65039ad"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dde599e0388e04778480d57f49355c9cc7916de818bf674de5d5429f2feebfb6"},
{file = "pydantic-1.10.26-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8be08b5cfe88e58198722861c7aab737c978423c3a27300911767931e5311d0d"},
{file = "pydantic-1.10.26-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0141f4bafe5eda539d98c9755128a9ea933654c6ca4306b5059fc87a01a38573"},
{file = "pydantic-1.10.26-cp38-cp38-win_amd64.whl", hash = "sha256:eb664305ffca8a9766a8629303bb596607d77eae35bb5f32ff9245984881b638"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:502b9d30d18a2dfaf81b7302f6ba0e5853474b1c96212449eb4db912cb604b7d"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0d8f6087bf697dec3bf7ffcd7fe8362674f16519f3151789f33cbe8f1d19fc15"},
{file = "pydantic-1.10.26-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd40a99c358419910c85e6f5d22f9c56684c25b5e7abc40879b3b4a52f34ae90"},
{file = "pydantic-1.10.26-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ce3293b86ca9f4125df02ff0a70be91bc7946522467cbd98e7f1493f340616ba"},
{file = "pydantic-1.10.26-cp39-cp39-win_amd64.whl", hash = "sha256:1a4e3062b71ab1d5df339ba12c48f9ed5817c5de6cb92a961dd5c64bb32e7b96"},
{file = "pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917"},
{file = "pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e"},
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
]
[package.dependencies]
email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""}
typing-extensions = ">=4.2.0"
annotated-types = ">=0.6.0"
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
pydantic-core = "2.41.5"
typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras]
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
]
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]]
name = "pygments"
@@ -3388,88 +3502,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstrument"
version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75a7e17377d4405666bbaf126b1fd7bbb7e206d7246e6db3d62864d3d4790ae3"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5381cc6583d26e04d9298acded4242f4fe71986f1472c8aee6992c6816f0cac5"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ec08a530bef8d3492d31d8b0b12d0cfde09539f2a1c4b9678662ebc3c843e478"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d671168508129b472be570bc9aee361190ba917b997c703bd134bb4de445ce7"},
{file = "pyinstrument-5.1.2-cp310-cp310-win32.whl", hash = "sha256:5957a94f84564b374a7f856d1b322345d600964280b0d687b8ddcc483f21e576"},
{file = "pyinstrument-5.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:38a2180a7801c51610b50e5d423674b21872efd019ccf05a11b7f9016cb1dcfc"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021"},
{file = "pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca"},
{file = "pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d"},
{file = "pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281"},
{file = "pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e"},
{file = "pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0"},
{file = "pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6"},
{file = "pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b"},
{file = "pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:47f14f248108f1202d48f34903bddc053a47c62ce46908aee848c1f667a1925b"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc3f2688981af764fa2c5f5a00d7040c0c12771ca5a026730f2d826f7a28d277"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7afb24d11d24fb1762059240ed97a1a4607779d5f221f91d62b67ae089bd506d"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d21221a29718d5dcdc1453dbbbdd100734525672ef1e34ff03d49bc5d688ca4"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3b68aa9f0d5217c67370762c99a80750ce56f66e5e9281c31b5baa3e4b88894d"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:755702f01800934c3bec3c0d30483f97b6ff1fc1d2afcf6d816584703c9f1235"},
{file = "pyinstrument-5.1.2-cp38-cp38-win32.whl", hash = "sha256:2bacb980c95d4c9ea6a253e5ccf3e99993082de29ff7e7fc397e9484355577b1"},
{file = "pyinstrument-5.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:2588bc34c25d50f29d3a117c7dfac06513ee59c507b65081e66ca9569bcf45e0"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bea0687665c181c6e62677fb560739a473c4286816582a43f8eb0aa6094ed529"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64246d2bd475870b62ed5df4808bfb33328135e8dfbdff823f9cb7d1358eb40b"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff6fd3c7907e57f082cdd405bec1b34768c1810a165538299d259ee1bff5d7b6"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af09af38ee8407ca273407e24a8e6470d2444561d01005ee6a8db5f2fd908c08"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1249d2799cdd57151b4444167e5c7736e2c9b5e79bd781b0779d631338509553"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d653206f50260f20bc78339c3d7aa0f19f8cf9c9f71939fbf02e2ea30353487"},
{file = "pyinstrument-5.1.2-cp39-cp39-win32.whl", hash = "sha256:d0b0c6e289725f14d0ff73f8190c953bdcb98f21c5c29c3eafb0dca8025583cb"},
{file = "pyinstrument-5.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:db8243e602aca43dc7ce8e40ed7d0ca4820d024c3c03824870c5a9e98f84e953"},
{file = "pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0"},
]
[package.extras]
bin = ["click", "nox"]
docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"]
examples = ["django", "litestar", "numpy"]
test = ["cffi (>=1.17.0)", "flaky", "greenlet (>=3)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"]
types = ["typing_extensions"]
[[package]]
name = "pyjwt"
version = "2.12.1"
@@ -4558,6 +4590,21 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -5110,4 +5157,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "0879a6230bbc0d0e1d8d7d090e1572ba863078b18e0d78478baf2100aa245e08"
content-hash = "42433ecd056377e5a537ebf0d9aeb70fc2bc275276ba9721310e8efc7a7d933b"
+9 -9
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.4"
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" }]
@@ -9,15 +9,15 @@ readme = "README.md"
dependencies = [
"bech32~=1.2.0",
"click~=8.3.1",
"fastapi~=0.116.1",
"starlette~=0.47.1",
"fastapi~=0.135.0",
"starlette~=1.0.0",
"httpx~=0.27.2",
"jinja2~=3.1.6",
"lnurl~=0.10.0",
"pydantic~=1.10.26",
"lnurl~=2.0.0",
"pydantic~=2.12.0",
"pyqrcode~=1.2.1",
"shortuuid~=1.0.13",
"sse-starlette~=2.3.6",
"sse-starlette~=3.3.3",
"typing-extensions~=4.15.0",
"uvicorn~=0.40.0",
"sqlalchemy~=1.4.54",
@@ -37,7 +37,7 @@ dependencies = [
"bolt11~=2.1.1",
"pyjwt~=2.12.0",
"itsdangerous~=2.2.0",
"fastapi-sso~=0.19.0",
"fastapi-sso~=0.21.0",
# needed for boltz, lnurldevice, watchonly extensions
"embit~=0.8.0",
# needed for scheduler extension
@@ -51,6 +51,7 @@ dependencies = [
"pillow~=12.1.0",
"python-dotenv~=1.2.1",
"greenlet~=3.3.0",
"pydantic-settings>=2.13.1",
]
[project.scripts]
@@ -82,8 +83,7 @@ dev = [
"pytest-mock~=3.15.1",
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
"grpcio-tools~=1.76.0"
]
[tool.poetry]
-109
View File
@@ -1,9 +1,6 @@
from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -52,109 +49,3 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
@pytest.mark.anyio
async def test_admin_audit_monitor_and_test_email(
client: AsyncClient, superuser_token: str, mocker
):
mocker.patch(
"lnbits.core.views.admin_api.get_balance_delta",
mocker.AsyncMock(
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
),
)
mocker.patch(
"lnbits.core.views.admin_api.send_email_notification",
mocker.AsyncMock(return_value={"status": "queued"}),
)
audit = await client.get(
"/admin/api/v1/audit",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert audit.status_code == 200
assert audit.json()["lnbits_balance_sats"] == 21
monitor = await client.get(
"/admin/api/v1/monitor",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
test_email = await client.get(
"/admin/api/v1/testemail",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert test_email.status_code == 200
assert test_email.json()["status"] == "queued"
@pytest.mark.anyio
async def test_admin_partial_reset_restart_and_backup(
client: AsyncClient,
superuser_token: str,
settings: Settings,
tmp_path,
):
response = await client.patch(
"/admin/api/v1/settings",
json={"lnbits_site_title": "PATCHED TITLE"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["status"] == "Success"
default_value = await client.get(
"/admin/api/v1/settings/default",
params={"field_name": "lnbits_site_title"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert default_value.status_code == 200
assert "default_value" in default_value.json()
backup_path = Path("lnbits-backup.zip")
original_data_folder = settings.lnbits_data_folder
try:
data_folder = tmp_path / "backup_data"
data_folder.mkdir(parents=True, exist_ok=True)
(data_folder / "sample.txt").write_text("backup me")
settings.lnbits_data_folder = str(data_folder)
backup = await client.get(
"/admin/api/v1/backup",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert backup.status_code == 200
assert backup.headers["content-type"] == "application/zip"
assert backup.content.startswith(b"PK")
assert backup_path.is_file()
finally:
settings.lnbits_data_folder = original_data_folder
backup_path.unlink(missing_ok=True)
server_restart.clear()
restart = await client.get(
"/admin/api/v1/restart",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert restart.status_code == 200
assert restart.json()["status"] == "Success"
assert server_restart.is_set() is True
server_restart.clear()
@pytest.mark.anyio
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert server_restart.is_set() is True
server_restart.clear()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to):
async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
# check the payment status
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"])
response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
assert isinstance(response, dict)
assert response["paid"] is True
assert "details" in response
-135
View File
@@ -1,135 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.assets import get_user_asset
from lnbits.core.services.assets import create_user_asset
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
@pytest.mark.anyio
async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient,
user_headers_from: dict[str, str],
):
upload = await client.post(
"/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.txt", b"hello world", "text/plain")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.txt"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
assert page.status_code == 200
assert any(item["id"] == asset["id"] for item in page.json()["data"])
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.txt"
data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
)
assert data.status_code == 200
assert data.content == b"hello world"
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
updated = await client.put(
f"/api/v1/assets/{asset['id']}",
headers=user_headers_from,
json={"name": "renamed.txt", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.txt"
assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200
assert public_data.content == b"hello world"
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
missing = await client.get(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert missing.status_code == 404
@pytest.mark.anyio
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
client: AsyncClient,
from_user,
to_user,
superuser_token: str,
):
private_asset = await create_user_asset(
from_user.id,
make_upload_file(
get_png_bytes(),
filename=f"private_{uuid4().hex[:8]}.png",
content_type="image/png",
),
is_public=False,
)
other_user_headers = await get_user_token_headers(client, to_user.id)
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert anonymous.status_code == 404
wrong_user = await client.get(
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
)
assert wrong_user.status_code == 404
admin_updated = await client.put(
f"/api/v1/assets/{private_asset.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"is_public": True, "name": "admin-visible.png"},
)
assert admin_updated.status_code == 200
assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200
assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png"
@pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient,
user_headers_from: dict[str, str],
):
invalid = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
)
assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"]
missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from,
)
assert missing.status_code == 404
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
assert missing_thumb.status_code == 404
stored = await create_user_asset(
"missing-user-check",
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
is_public=True,
)
fetched = await get_user_asset("missing-user-check", stored.id)
assert fetched is not None
-64
View File
@@ -1,64 +0,0 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.audit import create_audit_entry
from lnbits.core.models import AuditEntry
@pytest.mark.anyio
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
response = await client.get("/audit/api/v1", headers=user_headers_from)
assert response.status_code == 403
@pytest.mark.anyio
async def test_audit_api_returns_entries_and_stats(
client: AsyncClient,
superuser_token: str,
):
component = f"audit_component_{uuid4().hex[:8]}"
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.1",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="GET",
response_code="200",
duration=0.12,
created_at=datetime.now(timezone.utc),
)
)
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.2",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="POST",
response_code="400",
duration=2.5,
created_at=datetime.now(timezone.utc),
)
)
headers = {"Authorization": f"Bearer {superuser_token}"}
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
assert page.status_code == 200
page_data = page.json()
assert page_data["total"] == 2
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
stats = await client.get(
f"/audit/api/v1/stats?component={component}",
headers=headers,
)
assert stats.status_code == 200
payload = stats.json()
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
assert payload["component"][0]["field"] == component
assert payload["long_duration"][0]["field"] == "/api/v1/test"
-203
View File
@@ -1,203 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.responses import RedirectResponse
from httpx import AsyncClient
from lnbits.core.crud.users import get_account, update_account
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.auth_api import get_account_by_email
from lnbits.settings import Settings
class _FakeSSO:
def __init__(self, userinfo: object | None = None, state: str = ""):
self.userinfo = userinfo
self.state = state
self.redirect_uri: str | None = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
async def get_login_redirect(self, state: str):
self.state = state
return RedirectResponse("https://example.com/sso/login")
async def verify_and_process(self, _request):
return self.userinfo
@pytest.mark.anyio
async def test_auth_api_logout_and_update_ui_customization(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.patch(
f"/api/v1/auth/ui?usr={user.id}",
json={"theme": "amber", "walletLayout": "grid"},
)
assert response.status_code == 200
assert response.json()["ui_customization"]["theme"] == "amber"
assert response.json()["ui_customization"]["walletLayout"] == "grid"
logout = await http_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
assert logout.json()["status"] == "success"
assert "cookie_access_token=" in logout.headers["set-cookie"]
@pytest.mark.anyio
async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
provider = "github"
login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
assert login_sso.state
email = f"sso_{uuid4().hex[:8]}@lnbits.com"
callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="")
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso)
callback = await http_client.get(f"/api/v1/auth/{provider}/token")
assert callback.status_code == 307
assert callback.headers["location"] == "/wallet"
account = await get_account_by_email(email, active_only=False)
assert account is not None
assert account.email == email
assert account.extra.email_verified is True
@pytest.mark.anyio
async def test_auth_api_first_install_success_and_validation(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
first_install_token = f"install_{uuid4().hex[:8]}"
new_username = f"reinstall_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.first_install_token = first_install_token
missing_token = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert missing_token.status_code == 401
assert missing_token.json()["detail"] == "Missing first_install_token."
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
"first_install_token": first_install_token,
},
)
assert success.status_code == 200
assert success.json()["access_token"]
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" not in success.headers["set-cookie"]
updated_superuser = await get_account(settings.super_user, active_only=False)
assert updated_superuser is not None
assert updated_superuser.username == new_username
assert settings.first_install is False
forbidden = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": f"blocked_{uuid4().hex[:8]}",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert forbidden.status_code == 403
assert forbidden.json()["detail"] == "This is not your first install"
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
@pytest.mark.anyio
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_auth_https_only = settings.auth_https_only
new_username = f"secure_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.auth_https_only = True
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert success.status_code == 200
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" in success.headers["set-cookie"]
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.auth_https_only = original_auth_https_only
-184
View File
@@ -1,184 +0,0 @@
import json
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import (
handle_paypal_event,
handle_stripe_event,
)
@pytest.mark.anyio
async def test_callback_api_generic_webhook_handler_routes_providers(
http_client: AsyncClient, mocker
):
stripe_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock()
)
paypal_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
)
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
)
stripe = await http_client.post(
"/api/v1/callback/stripe",
headers={"Stripe-Signature": "sig"},
json={"id": "evt_1", "type": "payment_intent.succeeded"},
)
assert stripe.status_code == 200
assert stripe.json()["success"] is True
stripe_mock.assert_awaited_once()
paypal = await http_client.post(
"/api/v1/callback/paypal",
json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"},
)
assert paypal.status_code == 200
assert paypal.json()["success"] is True
paypal_mock.assert_awaited_once()
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
assert unknown.status_code == 200
assert unknown.json()["success"] is False
@pytest.mark.anyio
async def test_callback_api_handles_paid_events_with_real_payments(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback")
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_stripe",
"type": "payment_intent.succeeded",
"data": {
"object": {
"object": "payment_intent",
"metadata": {"payment_hash": payment.payment_hash},
}
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal_approved",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal",
"event_type": "PAYMENT.CAPTURE.COMPLETED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
assert fiat_status_mock.await_count == 2
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_invoice_paid",
"type": "invoice.paid",
"data": {
"object": {
"id": "invoice_1",
"currency": "usd",
"amount_paid": 500,
"hosted_invoice_url": "https://stripe.example/invoice",
"customer_email": "alice@example.com",
"lines": {"data": [{"description": "Gold Plan"}]},
"parent": {
"type": "subscription_details",
"subscription_details": {
"metadata": {
"alan_action": "subscription",
"wallet_id": wallet.id,
"tag": "gold",
"memo": "Monthly Gold",
"extra": json.dumps({"plan": "gold"}),
}
},
},
}
},
}
)
create_fiat_invoice_mock.assert_awaited()
fiat_status_mock.assert_awaited()
await handle_paypal_event(
{
"id": "evt_sale_completed",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {
"id": "sale_1",
"billing_agreement_id": "agreement_1",
"amount": {"currency": "USD", "total": "7.50"},
"custom_id": json.dumps(
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
),
},
}
)
assert create_fiat_invoice_mock.await_count == 2
with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata."
):
await handle_paypal_event(
{
"id": "evt_bad_sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
}
)
-430
View File
@@ -1,430 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
get_installed_extension,
get_user_extension,
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
api_disable_extension,
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
create_extension_review,
delete_extension_db,
extensions,
get_extension_release,
get_extension_releases,
get_extension_reviews,
get_extension_reviews_tags,
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from tests.helpers import make_extension_release, make_installable_extension
class _MockHTTPResponse:
def __init__(
self,
*,
json_data=None,
text: str = "",
status_code: int = 200,
is_error: bool = False,
):
self._json_data = json_data
self.text = text
self.status_code = status_code
self.is_error = is_error
def json(self):
return self._json_data
def raise_for_status(self):
if self.status_code >= 400:
raise ValueError(self.text or "request failed")
class _MockHTTPClient:
def __init__(self, responses: dict[str, _MockHTTPResponse]):
self.responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, url: str):
return self.responses[url]
async def post(self, url: str, json=None):
return self.responses[url]
@pytest.mark.anyio
async def test_extension_api_install_details_and_release_endpoints(mocker):
ext_id = f"ext_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.views.extension_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
installed = await api_install_extension(create_data)
assert installed.code == ext_id
activate_mock.assert_awaited_once()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=[release]),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_details",
mocker.AsyncMock(return_value={"description": "Extension details"}),
)
details = await api_extension_details(ext_id, release.details_link or "")
assert details["description"] == "Extension details"
assert details["icon"] == release.icon
assert details["repo"] == release.repo
installed_ext = make_installable_extension(
ext_id,
payments=[
ReleasePaymentInfo(
amount=55,
pay_link=release.pay_link,
payment_hash=f"payment_{uuid4().hex[:8]}",
)
],
)
await create_installed_extension(installed_ext)
releases = await get_extension_releases(ext_id)
assert releases[0].paid_sats == 55
config = ExtensionConfig(
name=ext_id,
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
)
mocker.patch.object(
ExtensionConfig,
"fetch_github_release_config",
mocker.AsyncMock(return_value=config),
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_account = await get_account(admin_user.id)
assert admin_account is not None
admin_wallet = await create_wallet(
user_id=admin_account.id, wallet_name="extension sales"
)
ext_id = f"paid_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(
required=True, amount=10, wallet=admin_wallet.id
),
)
)
updated = await api_update_pay_to_enable(
ext_id,
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
account=admin_account,
)
assert updated.success is True
stored_extension = await get_installed_extension(ext_id)
assert stored_extension is not None
assert stored_extension.meta is not None
assert stored_extension.meta.pay_to_enable is not None
assert stored_extension.meta.pay_to_enable.amount == 21
enable_invoice = await create_wallet_invoice(
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
)
mocker.patch(
"lnbits.core.views.extension_api.create_invoice",
mocker.AsyncMock(return_value=enable_invoice),
)
invoice_response = await get_pay_to_enable_invoice(
ext_id,
PayToEnableInfo(amount=21),
account_id=AccountId(id=regular_user.id),
)
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.extra is not None
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
)
mocker.patch(
"lnbits.core.views.extension_api.check_transaction_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
assert enabled.success is True
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
assert user_ext.extra == UserExtensionInfo(
payment_hash_to_enable=enable_invoice.payment_hash,
paid_to_enable=True,
)
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled.success is True
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled_again.success is True
assert "already disabled" in disabled_again.message
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
Extension(code="other", is_valid=True),
]
),
)
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
assert [ext.code for ext in visible_extensions] == [ext_id]
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(return_value=[catalog_entry]),
)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
@pytest.mark.anyio
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
base_ext = f"base_{uuid4().hex[:8]}"
dependent_ext = f"dependent_{uuid4().hex[:8]}"
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
db_ext = f"db_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(base_ext))
await create_installed_extension(
make_installable_extension(dependent_ext, dependencies=[base_ext])
)
await create_installed_extension(make_installable_extension(uninstall_ext))
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=base_ext, is_valid=True, name="Base"),
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
]
),
)
with pytest.raises(HTTPException, match="depends on this one"):
await api_uninstall_extension(base_ext)
uninstall_mock = mocker.patch(
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
)
uninstalled = await api_uninstall_extension(uninstall_ext)
assert uninstalled.success is True
uninstall_mock.assert_awaited_once_with(uninstall_ext)
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extension",
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
deactivate_mock = mocker.patch(
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
)
activated = await api_activate_extension(base_ext)
assert activated.success is True
deactivated = await api_deactivate_extension(base_ext)
assert deactivated.success is True
activate_mock.assert_awaited_once()
deactivate_mock.assert_awaited_once()
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = owner.wallets[0]
install_invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
)
release = make_extension_release(base_ext, version="2.0.0")
payment_info = ReleasePaymentInfo(
amount=33,
pay_link=release.pay_link,
payment_hash=install_invoice.payment_hash,
payment_request=install_invoice.bolt11,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_payment_info",
mocker.AsyncMock(return_value=payment_info),
)
invoice = await get_pay_to_install_invoice(
base_ext,
CreateExtension(
ext_id=base_ext,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
cost_sats=33,
),
)
assert invoice.payment_hash == install_invoice.payment_hash
await update_migration_version(None, db_ext, 1)
drop_mock = mocker.patch(
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
)
deleted = await delete_extension_db(db_ext)
assert deleted.success is True
drop_mock.assert_awaited_once_with(ext_id=db_ext)
assert await get_db_version(db_ext) is None
@pytest.mark.anyio
async def test_extension_api_review_endpoints(mocker):
ext_id = f"review_{uuid4().hex[:8]}"
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
tags_url = f"{reviews_base}/tags"
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
create_review_url = f"{reviews_base}/reviews"
request = Request(
{
"type": "http",
"method": "GET",
"path": f"/api/v1/extension/reviews/{ext_id}",
"query_string": b"offset=0&limit=5",
"headers": [],
}
)
mock_client = _MockHTTPClient(
{
tags_url: _MockHTTPResponse(
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
),
reviews_url: _MockHTTPResponse(
json_data={
"data": [
{
"id": "1",
"name": "Alice",
"tag": "good",
"rating": 950,
"comment": "solid",
}
],
"total": 1,
}
),
create_review_url: _MockHTTPResponse(
json_data={
"payment_hash": f"hash_{uuid4().hex[:8]}",
"payment_request": "lnbc1review",
}
),
}
)
mocker.patch(
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
)
tags = await get_extension_reviews_tags()
assert tags[0].tag == "good"
reviews = await get_extension_reviews(ext_id, request)
assert reviews.total == 1
assert reviews.data[0].comment == "solid"
payment_request = await create_extension_review(
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
-109
View File
@@ -1,109 +0,0 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
from lnbits.core.crud.users import get_account
from lnbits.core.models.extensions import (
Extension,
UserExtension,
)
from lnbits.core.models.users import AccountId
from lnbits.core.views.extensions_builder_api import (
api_build_extension,
api_delete_extension_builder_data,
api_deploy_extension,
api_preview_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data, make_extension_release
@pytest.mark.anyio
async def test_extensions_builder_api_build_preview_and_cleanup(
tmp_path, settings: Settings, mocker, from_user
):
ext_id = f"builder_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
build_dir = tmp_path / "build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "index.txt").write_text("hello")
original_data_folder = settings.lnbits_data_folder
build_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
),
)
clean_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
)
try:
settings.lnbits_data_folder = str(tmp_path)
build_response = await api_build_extension(data)
assert Path(build_response.path).is_file()
assert build_response.filename == f"{ext_id}.zip"
preview = await api_preview_extension(data, AccountId(id=from_user.id))
assert preview.success is True
assert ext_id in preview.message
cleaned = await api_delete_extension_builder_data()
assert cleaned.success is True
clean_mock.assert_called_once()
assert build_mock.await_count == 2
finally:
settings.lnbits_data_folder = original_data_folder
@pytest.mark.anyio
async def test_extensions_builder_api_deploy_updates_user_extension(
tmp_path, settings: Settings, mocker, admin_user
):
ext_id = f"deploy_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
account = await get_account(admin_user.id)
assert account is not None
build_root = tmp_path / "deploy-root" / ext_id
build_root.mkdir(parents=True, exist_ok=True)
(build_root / "manifest.json").write_text("{}")
original_data_folder = settings.lnbits_data_folder
await create_user_extension(
UserExtension(user=account.id, extension=ext_id, active=False)
)
mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
),
)
install_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.activate_extension",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path)
deployed = await api_deploy_extension(data, account=account)
finally:
settings.lnbits_data_folder = original_data_folder
assert deployed.success is True
assert ext_id in deployed.message
install_mock.assert_awaited_once()
activate_mock.assert_awaited_once()
user_ext = await get_user_extension(account.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
-146
View File
@@ -1,146 +0,0 @@
import pytest
from httpx import AsyncClient
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
class _UnsetSecret:
pass
UNSET_SECRET = _UnsetSecret()
class FakeStripeWallet:
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
self._secret = (
"connection-token" if isinstance(secret, _UnsetSecret) else secret
)
async def create_terminal_connection_token(self) -> dict[str, str]:
if self._secret is None:
return {}
return {"secret": self._secret}
@pytest.mark.anyio
async def test_fiat_api_test_provider_and_subscription_lifecycle(
client: AsyncClient,
superuser_token: str,
adminkey_headers_from: dict[str, str],
from_wallet,
mocker: MockerFixture,
):
test_connection = mocker.patch(
"lnbits.core.views.fiat_api.test_connection",
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
)
response = await client.put(
"/api/v1/fiat/check/stripe",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["success"] is True
test_connection.assert_awaited_once_with("stripe")
provider = mocker.Mock()
provider.create_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(
ok=True,
subscription_request_id="sub-1",
checkout_session_url="https://stripe.example/checkout",
)
)
provider.cancel_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
)
get_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=provider),
)
mismatch = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"wallet_id": "wrong-wallet"},
},
)
assert mismatch.status_code == 403
created = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
},
)
assert created.status_code == 200
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
provider.create_subscription.assert_awaited_once()
assert provider.create_subscription.await_args is not None
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
cancelled = await client.delete(
"/api/v1/fiat/stripe/subscription/sub-1",
headers=adminkey_headers_from,
)
assert cancelled.status_code == 200
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
assert get_provider.await_count == 3
@pytest.mark.anyio
async def test_fiat_api_connection_token_validates_provider_configuration(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
headers = {"Authorization": f"Bearer {superuser_token}"}
not_found = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=None),
)
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert missing.status_code == 404
assert not_found.await_count == 1
unsupported_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=object()),
)
unsupported = await client.post(
"/api/v1/fiat/paypal/connection_token", headers=headers
)
assert unsupported.status_code == 400
assert unsupported_provider.await_count == 1
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
bad_wallet = FakeStripeWallet(secret=None)
bad_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=bad_wallet),
)
no_secret = await client.post(
"/api/v1/fiat/stripe/connection_token", headers=headers
)
assert no_secret.status_code == 500
assert no_secret.json()["detail"] == "Failed to create connection token"
assert bad_provider.await_count == 1
good_wallet = FakeStripeWallet(secret="tok_live")
good_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=good_wallet),
)
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
assert good_provider.await_count == 1
-142
View File
@@ -1,142 +0,0 @@
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnurl import (
LnAddress,
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
)
from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
api_lnurlscan,
api_lnurlscan_post,
api_payments_pay_lnurl,
api_perform_lnurlauth,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=pay_response),
)
scanned = await api_lnurlscan("lnurl1example")
assert isinstance(scanned, LnurlPayResponse)
assert scanned.callback == pay_response.callback
scanned_post = await api_lnurlscan_post(
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
)
assert isinstance(scanned_post, LnurlPayResponse)
assert scanned_post.callback == pay_response.callback
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
)
with pytest.raises(HTTPException, match="blocked callback"):
await api_lnurlscan("lnurl1blocked")
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
)
with pytest.raises(HTTPException, match="invalid lnurl"):
await api_lnurlscan("lnurl1invalid")
@pytest.mark.anyio
async def test_lnurl_api_auth_and_pay_flow(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
pay_response = make_lnurl_pay_response()
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
)
auth_response = LnurlAuthResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
k1="k1-value",
)
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(return_value=auth_response),
)
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
assert isinstance(authenticated, LnurlAuthResponse)
assert authenticated.k1 == "k1-value"
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
)
with pytest.raises(HTTPException, match="denied"):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
fetch_mock = mocker.patch(
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
mocker.AsyncMock(return_value=(pay_response, action_response)),
)
pay_mock = mocker.patch(
"lnbits.core.views.lnurl_api.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
paid = await api_payments_pay_lnurl(
CreateLnurlPayment(
res=pay_response, amount=2_000, unit="USD", comment="thanks"
),
wallet_info,
)
assert paid.payment_hash == payment.payment_hash
fetch_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert action_response.successAction is not None
assert pay_mock.await_args.kwargs["extra"] == {
"stored": True,
"success_action": action_response.successAction.json(),
"comment": "thanks",
"fiat_currency": "USD",
"fiat_amount": 2.0,
}
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
-366
View File
@@ -1,366 +0,0 @@
from collections.abc import Callable
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from fastapi import HTTPException
from pytest_mock.plugin import MockerFixture
from lnbits.core.views import node_api
from lnbits.db import Filters, Page
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodePayment,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.settings import Settings
from lnbits.wallets.base import Feature
class FakeNode:
def __init__(self):
self.channel = NodeChannel(
id="chan-1",
short_id="123x1x0",
peer_id="peer-1",
name="Peer One",
color="#ffffff",
state=ChannelState.ACTIVE,
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
fee_ppm=10,
fee_base_msat=1000,
)
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
self.info = NodeInfoResponse(
id="node-id",
backend_name="FakeNode",
alias="Fake Alias",
color="#ffffff",
num_peers=1,
blockheight=1,
channel_stats=ChannelStats(
counts={ChannelState.ACTIVE: 1},
avg_size=3000,
biggest_size=3000,
smallest_size=3000,
total_capacity=3000,
),
addresses=["127.0.0.1:9735"],
onchain_balance_sat=1,
onchain_confirmed_sat=1,
fees=NodeFees(total_msat=0),
balance_msat=3000,
)
self.fees_updated: tuple[str, int | None, int | None] | None = None
async def get_public_info(self) -> PublicNodeInfo:
return PublicNodeInfo(**self.info.dict())
async def get_info(self) -> NodeInfoResponse:
return self.info
async def get_channels(self) -> list[NodeChannel]:
return [self.channel]
async def get_channel(self, channel_id: str) -> NodeChannel | None:
return self.channel if channel_id == self.channel.id else None
async def open_channel(
self,
peer_id: str,
funding_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
assert peer_id == "peer-1"
assert funding_amount == 10_000
assert push_amount == 100
assert fee_rate == 5
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
) -> list[NodeChannel]:
assert short_id == self.channel.short_id
assert point is None
assert force is True
return [self.channel]
async def set_channel_fee(
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
) -> None:
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
return Page(
data=[
NodePayment(
pending=False,
amount=1,
fee=0,
memo="payment",
time=1,
preimage="11" * 32,
payment_hash="22" * 32,
)
],
total=1,
)
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
return Page(
data=[
NodeInvoice(
pending=False,
amount=1,
memo="invoice",
bolt11="lnbc1dummy",
preimage="11" * 32,
payment_hash="33" * 32,
)
],
total=1,
)
async def get_peers(self) -> list[NodePeerInfo]:
return [self.peer]
async def connect_peer(self, uri: str) -> dict[str, str]:
return {"uri": uri}
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
return {"peer_id": peer_id}
async def get_id(self) -> str:
return "fake-node-id"
class FakeFundingSource:
def __init__(
self,
features: list[Feature],
node_factory: Callable[[Any], Any] | None,
):
self.features = features
self.__node_cls__ = node_factory
class MockHTTPResponse:
def __init__(
self, json_data: dict[str, Any], status_error: Exception | None = None
):
self._json_data = json_data
self._status_error = status_error
def raise_for_status(self) -> None:
if self._status_error:
raise self._status_error
def json(self) -> dict[str, Any]:
return self._json_data
class MockHTTPClient:
def __init__(self, response: MockHTTPResponse):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int):
self.calls.append(url)
return self.response
@pytest.mark.anyio
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
original_node_ui = settings.lnbits_node_ui
original_public = settings.lnbits_public_node_ui
try:
settings.lnbits_node_ui = True
funding_source = FakeFundingSource([], None)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=funding_source,
)
with pytest.raises(HTTPException) as excinfo:
node_api.require_node()
assert excinfo.value.status_code == 501
node_enabled_source = FakeFundingSource(
[Feature.nodemanager], lambda wallet: "fake-node"
)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=node_enabled_source,
)
settings.lnbits_node_ui = False
with pytest.raises(HTTPException) as disabled:
node_api.require_node()
assert disabled.value.status_code == 503
settings.lnbits_node_ui = True
assert node_api.require_node() == "fake-node"
settings.lnbits_public_node_ui = False
with pytest.raises(HTTPException) as public_disabled:
node_api.check_public()
assert public_disabled.value.status_code == 503
finally:
settings.lnbits_node_ui = original_node_ui
settings.lnbits_public_node_ui = original_public
@pytest.mark.anyio
async def test_node_api_route_functions_with_fake_node(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = True
rank_response = MockHTTPResponse(
{
"noderank": {
"capacity": 1,
"channelcount": 2,
"age": 3,
"growth": 4,
"availability": 5,
}
}
)
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(rank_response),
)
try:
assert await node_api.api_get_ok() is None
public_info = await node_api.api_get_public_info(node=node)
assert public_info.backend_name == "FakeNode"
info = await node_api.api_get_info(node=node)
assert info is not None
assert info.id == "node-id"
channels = await node_api.api_get_channels(node=node)
assert channels is not None
assert channels[0].id == "chan-1"
channel = await node_api.api_get_channel("chan-1", node=node)
assert channel is not None
assert channel.peer_id == "peer-1"
created = await node_api.api_create_channel(
node=node,
peer_id="peer-1",
funding_amount=10_000,
push_amount=100,
fee_rate=5,
)
assert created.output_index == 0
deleted = await node_api.api_delete_channel(
short_id="123x1x0",
funding_txid=None,
output_index=None,
force=True,
node=node,
)
assert deleted is not None
assert deleted[0].id == "chan-1"
await node_api.api_set_channel_fees(
"chan-1",
node=node,
fee_ppm=42,
fee_base_msat=7,
)
assert fake_node.fees_updated == ("chan-1", 7, 42)
payments = await node_api.api_get_payments(node=node, filters=Filters())
assert payments is not None
assert payments.total == 1
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices is not None
assert invoices.total == 1
peers = await node_api.api_get_peers(node=node)
assert peers[0].id == "peer-1"
connect = await node_api.api_connect_peer(
uri="peer-1@127.0.0.1:9735", node=node
)
assert connect["uri"] == "peer-1@127.0.0.1:9735"
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
assert disconnect["peer_id"] == "peer-1"
rank = await node_api.api_get_1ml_stats(node=node)
assert rank is not None
rank_data = node_api.NodeRank.parse_obj(rank)
assert rank_data.channelcount == 2
finally:
settings.lnbits_node_ui_transactions = original_transactions
@pytest.mark.anyio
async def test_node_api_transactions_and_rank_errors(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = False
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(
MockHTTPResponse(
{},
status_error=httpx.HTTPStatusError(
"not found", request=request, response=httpx.Response(404)
),
)
),
)
try:
with pytest.raises(HTTPException) as payments:
await node_api.api_get_payments(node=node, filters=Filters())
assert payments.value.status_code == 503
with pytest.raises(HTTPException) as invoices:
await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices.value.status_code == 503
with pytest.raises(HTTPException) as rank:
await node_api.api_get_1ml_stats(node=node)
assert rank.value.status_code == 404
assert rank.value.detail == "Node not found on 1ml.com"
finally:
settings.lnbits_node_ui_transactions = original_transactions
-187
View File
@@ -1,187 +0,0 @@
import json
from hashlib import sha256
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
api_all_payments_paginated,
api_payments_cancel,
api_payments_counting_stats,
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_payment_api_stats_and_all_paginated(admin_user):
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
count_stats = await api_payments_counting_stats(
count_by="tag",
filters=Filters(limit=20),
account_id=AccountId(id=first_user.id),
)
assert any(item.field == "coffee" for item in count_stats)
assert all(item.field != "books" for item in count_stats)
wallet_stats = await api_payments_wallets_stats(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
daily_stats = await api_payments_daily_stats(
account_id=AccountId(id=first_user.id),
filters=Filters(limit=20),
)
assert daily_stats
assert daily_stats[0].payments_count >= 1
regular_page = await api_all_payments_paginated(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert regular_page.total >= 2
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
admin_page = await api_all_payments_paginated(
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
)
wallet_ids = {payment.wallet_id for payment in admin_page.data}
assert first_wallet.id in wallet_ids
assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
preimage = "11" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
await _create_payment(
wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
settle_mock = mocker.patch(
"lnbits.core.views.payment_api.settle_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=True,
checking_id="settled",
preimage=preimage,
)
),
)
settled = await api_payments_settle(
SettleInvoice(preimage=preimage),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert settled.success is True
settle_mock.assert_awaited_once()
cancel_hash = (uuid4().hex * 2)[:64]
await _create_payment(
wallet.id,
amount_msat=2_000,
payment_hash=cancel_hash,
status=PaymentState.PENDING,
)
cancel_mock = mocker.patch(
"lnbits.core.views.payment_api.cancel_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=False,
checking_id="cancelled",
error_message="cancelled",
)
),
)
cancelled = await api_payments_cancel(
CancelInvoice(payment_hash=cancel_hash),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert cancelled.failed is True
cancel_mock.assert_awaited_once()
async def _create_payment(
wallet_id: str,
*,
amount_msat: int,
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash or uuid4().hex,
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
),
status=status,
)
return checking_id
-71
View File
@@ -1,71 +0,0 @@
from http import HTTPStatus
import pytest
from httpx import AsyncClient
@pytest.mark.anyio
async def test_tinyurl_api_create_get_redirect_and_delete(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
inkey_headers_from: dict[str, str],
inkey_headers_to: dict[str, str],
):
created = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/landing", "endless": "true"},
headers=adminkey_headers_from,
)
assert created.status_code == HTTPStatus.OK
tinyurl = created.json()
assert tinyurl["url"] == "https://example.com/landing"
assert tinyurl["endless"] is True
fetched = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_from,
)
assert fetched.status_code == HTTPStatus.OK
assert fetched.json()["id"] == tinyurl["id"]
wrong_wallet = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_to,
)
assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND
assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl"
redirect = await client.get(f"/t/{tinyurl['id']}")
assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT
assert redirect.headers["location"] == "https://example.com/landing"
deleted = await client.delete(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=adminkey_headers_from,
)
assert deleted.status_code == HTTPStatus.OK
assert deleted.json()["deleted"] is True
missing_redirect = await client.get(f"/t/{tinyurl['id']}")
assert missing_redirect.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.anyio
async def test_tinyurl_api_reuses_existing_entries_for_same_wallet(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
):
first = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
second = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
assert first.status_code == HTTPStatus.OK
assert second.status_code == HTTPStatus.OK
assert first.json()["id"] == second.json()["id"]
-102
View File
@@ -1,102 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets
from lnbits.core.models import UpdateBalance
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_create_user_wallet
from lnbits.settings import settings
@pytest.mark.anyio
async def test_user_api_toggle_admin_and_update_balance(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
promote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert promote.status_code == 200
assert settings.is_admin_user(user.id) is True
demote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert demote.status_code == 200
assert settings.is_admin_user(user.id) is False
balance = await http_client.put(
"/users/api/v1/balance",
headers={"Authorization": f"Bearer {superuser_token}"},
json=UpdateBalance(id=wallet.id, amount=7).dict(),
)
assert balance.status_code == 200
assert balance.json()["success"] is True
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet is not None
assert updated_wallet.balance == 7
@pytest.mark.anyio
async def test_user_api_get_wallets_and_delete_all_wallets(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare")
wallets = await http_client.get(
f"/users/api/v1/user/{user.id}/wallet",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert wallets.status_code == 200
wallet_ids = {wallet["id"] for wallet in wallets.json()}
assert extra_wallet.id in wallet_ids
deleted = await http_client.delete(
f"/users/api/v1/user/{user.id}/wallets",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
active_wallets = await get_wallets(user.id, deleted=False)
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."):
await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID")
wallet = await api_users_create_user_wallet(
user.id, name="eur wallet", currency="EUR"
)
assert wallet.currency == "EUR"
-190
View File
@@ -1,190 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
@pytest.mark.anyio
async def test_wallet_api_share_invite_reject_accept_and_delete(
http_client: AsyncClient,
):
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"owner_{uuid4().hex[:8]}",
email=f"owner_{uuid4().hex[:8]}@lnbits.com",
)
)
invited = await create_user_account(
Account(
id=uuid4().hex,
username=f"invited_{uuid4().hex[:8]}",
email=f"invited_{uuid4().hex[:8]}@lnbits.com",
)
)
source_wallet = owner.wallets[0]
owner_headers = _admin_headers(source_wallet.adminkey)
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
assert share_request["request_id"]
reject = await http_client.delete(
f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}"
)
assert reject.status_code == 200
assert reject.json()["success"] is True
removed_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert removed_share.status_code == 200
assert removed_share.json()["success"] is True
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments", "receive-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
create_shared = await http_client.post(
f"/api/v1/wallet?usr={invited.id}",
json={
"name": "shared",
"wallet_type": "lightning-shared",
"shared_wallet_id": source_wallet.id,
},
)
assert create_shared.status_code == 200
mirror_wallet = create_shared.json()
assert mirror_wallet["shared_wallet_id"] == source_wallet.id
approve = await http_client.put(
"/api/v1/wallet/share",
headers=owner_headers,
json={
"username": invited.username,
"shared_with_wallet_id": mirror_wallet["id"],
"permissions": ["view-payments", "receive-payments"],
"status": "approved",
},
)
assert approve.status_code == 200
assert approve.json()["status"] == "approved"
delete_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert delete_share.status_code == 200
assert delete_share.json()["success"] is True
assert await get_wallet(mirror_wallet["id"]) is None
@pytest.mark.anyio
async def test_wallet_api_paginated_update_reset_and_store_paylinks(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="second")
first_wallet = user.wallets[0]
page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10")
assert page.status_code == 200
assert page.json()["total"] >= 2
renamed = await http_client.put(
"/api/v1/wallet/renamed-wallet",
headers=_admin_headers(first_wallet.adminkey),
)
assert renamed.status_code == 200
assert renamed.json()["name"] == "renamed-wallet"
original_admin_key = extra_wallet.adminkey
reset = await http_client.put(
f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}"
)
assert reset.status_code == 200
assert reset.json()["adminkey"] != original_admin_key
stored = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(reset.json()["adminkey"]),
json={
"links": [
{
"lnurl": "alice@example.com",
"label": "Alice",
}
]
},
)
assert stored.status_code == 200
assert stored.json()[0]["lnurl"] == "alice@example.com"
forbidden = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(first_wallet.adminkey),
json={"links": []},
)
assert forbidden.status_code == 403
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(first_wallet.adminkey),
json={"icon": "bolt", "color": "amber", "pinned": True},
)
assert updated.status_code == 200
assert updated.json()["extra"]["icon"] == "bolt"
assert updated.json()["extra"]["color"] == "amber"
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.post(
f"/api/v1/wallet?usr={user.id}",
json={"wallet_type": "lightning-shared"},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Shared wallet ID is required for shared wallets."
)
def _admin_headers(adminkey: str) -> dict[str, str]:
return {"X-Api-Key": adminkey, "Content-type": "application/json"}
-31
View File
@@ -1,31 +0,0 @@
from unittest.mock import AsyncMock
from pytest_mock.plugin import MockerFixture
def test_websocket_api_connects_and_updates(test_client):
with test_client.websocket_connect("/api/v1/ws/demo-item") as websocket:
response = test_client.post("/api/v1/ws/demo-item", params={"data": "hello"})
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "hello"}
assert websocket.receive_text() == "hello"
response = test_client.get("/api/v1/ws/demo-item/world")
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "world"}
assert websocket.receive_text() == "world"
def test_websocket_api_reports_send_failures(test_client, mocker: MockerFixture):
mocker.patch(
"lnbits.core.views.websocket_api.websocket_manager.send",
AsyncMock(side_effect=RuntimeError("boom")),
)
post_response = test_client.post("/api/v1/ws/demo-item", params={"data": "oops"})
assert post_response.status_code == 200
assert post_response.json() == {"sent": False, "data": "oops"}
get_response = test_client.get("/api/v1/ws/demo-item/oops")
assert get_response.status_code == 200
assert get_response.json() == {"sent": False, "data": "oops"}
+1 -40
View File
@@ -1,6 +1,4 @@
import asyncio
import copy
import inspect
from datetime import datetime, timezone
from uuid import uuid4
@@ -25,12 +23,7 @@ from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.views.auth_api import first_install
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import (
AuthMethods,
EditableSettings,
FiatProviderLimits,
Settings,
)
from lnbits.settings import AuthMethods, FiatProviderLimits, Settings
from lnbits.settings import settings as lnbits_settings
from lnbits.wallets.fake import FakeWallet
from tests.helpers import (
@@ -40,23 +33,6 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex
# Snapshot the initialized module settings instead of a fresh Settings() instance.
# The module settings include runtime-populated values like `version`.
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
field_name
for field_name in Settings.readonly_fields()
if field_name != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
@pytest.fixture(scope="session")
@@ -96,7 +72,6 @@ async def app(settings: Settings):
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
)
@@ -352,21 +327,7 @@ async def new_user(username: str | None = None) -> User:
return user
def _restore_pure_settings(settings: Settings):
for field_name in _PURE_SETTINGS_FIELDS:
setattr(
settings, field_name, copy.deepcopy(getattr(_PURE_SETTINGS, field_name))
)
def _settings_cleanup(settings: Settings):
_restore_pure_settings(settings)
settings.auth_https_only = False
settings.lnbits_data_folder = "./tests/data"
settings.bundle_assets = True
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
-24
View File
@@ -1,24 +0,0 @@
networks:
lnbits-net:
services:
postgres-db:
container_name: postgres_container_test
image: postgres:14
environment:
POSTGRES_DB: lnbits
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
PGDATA: /lnbits/data/postgres
volumes:
- ./data/postgres_test:/lnbits/data/postgres
ports:
- '5444:5432'
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d lnbits_db']
interval: 5s
timeout: 5s
retries: 5
networks:
- lnbits-net
+1 -144
View File
@@ -1,33 +1,8 @@
import random
import string
from io import BytesIO
from bolt11.types import MilliSatoshi
from fastapi import UploadFile
from httpx import AsyncClient
from lnurl import LnurlPayResponse
from lnurl.types import CallbackUrl, LnurlPayMetadata
from PIL import Image
from pydantic import BaseModel, parse_obj_as
from starlette.datastructures import Headers
from pydantic.v1 import BaseModel
from lnbits.core.models.extensions import (
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
)
from lnbits.core.models.extensions_builder import (
ActionFields,
ClientDataFields,
DataField,
DataFields,
ExtensionData,
OwnerDataFields,
PublicPageFields,
SettingsFields,
)
from lnbits.wallets import get_funding_source, set_funding_source
@@ -65,125 +40,7 @@ async def get_random_invoice_data():
return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"}
def get_png_bytes(*, color: str = "blue", size: tuple[int, int] = (32, 32)) -> bytes:
image = Image.new("RGB", size, color=color)
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def make_upload_file(
contents: bytes,
*,
filename: str,
content_type: str | None,
) -> UploadFile:
headers = (
Headers({"content-type": content_type}) if content_type is not None else None
)
return UploadFile(BytesIO(contents), filename=filename, headers=headers)
async def get_user_token_headers(client: AsyncClient, user_id: str) -> dict[str, str]:
response = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
return {
"Authorization": f"Bearer {response.json()['access_token']}",
"Content-type": "application/json",
}
def make_extension_data(ext_id: str = "demoext") -> ExtensionData:
return ExtensionData(
id=ext_id,
name="Demo Extension",
stub_version="0.1.0",
short_description="Generated extension",
owner_data=DataFields(
name="OwnerData",
fields=[DataField(name="wallet_id", type="wallet")],
),
client_data=DataFields(
name="ClientData",
fields=[DataField(name="amount", type="int")],
),
settings_data=SettingsFields(name="SettingsData", fields=[]),
public_page=PublicPageFields(
owner_data_fields=OwnerDataFields(),
client_data_fields=ClientDataFields(),
action_fields=ActionFields(),
),
)
def make_extension_release(ext_id: str, version: str = "1.0.0") -> ExtensionRelease:
return ExtensionRelease(
name=ext_id,
version=version,
archive=f"https://example.com/{ext_id}.zip",
source_repo="org/repo",
hash=f"hash-{ext_id}",
details_link=f"https://example.com/{ext_id}/details.json",
repo=f"https://github.com/org/{ext_id}",
icon=f"/{ext_id}/static/icon.png",
pay_link=f"https://pay.example/{ext_id}",
is_github_release=False,
is_version_compatible=True,
)
def make_installable_extension(
ext_id: str,
*,
version: str = "1.0.0",
compatible: bool = True,
active: bool = True,
pay_to_enable: PayToEnableInfo | None = None,
dependencies: list[str] | None = None,
payments: list[ReleasePaymentInfo] | None = None,
) -> InstallableExtension:
release = make_extension_release(ext_id, version)
release.is_version_compatible = compatible
return InstallableExtension(
id=ext_id,
name=f"Extension {ext_id}",
version=version,
active=active,
short_description="Demo extension",
icon=release.icon,
meta=ExtensionMeta(
installed_release=release,
pay_to_enable=pay_to_enable,
dependencies=dependencies or [],
payments=payments or [],
),
)
def make_lnurl_pay_response(
*,
min_sendable_msat: int = 1_000,
max_sendable_msat: int = 10_000,
text: str = "Test payment",
identifier: str = "alice@example.com",
callback: str = "https://example.com/callback",
) -> LnurlPayResponse:
return LnurlPayResponse(
callback=parse_obj_as(CallbackUrl, callback),
minSendable=MilliSatoshi(min_sendable_msat),
maxSendable=MilliSatoshi(max_sendable_msat),
metadata=LnurlPayMetadata(
f"[["
f'"text/plain","{text}"'
f"],["
f'"text/identifier","{identifier}"'
f"]]"
),
)
set_funding_source()
funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake
+6 -6
View File
@@ -4,7 +4,7 @@ from http import HTTPStatus
import pytest
from httpx import AsyncClient, Headers
from pydantic import parse_obj_as
from pydantic import TypeAdapter
from lnbits import bolt11
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
@@ -111,7 +111,7 @@ async def test_get_channel(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json())
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE]
)
@@ -121,7 +121,7 @@ async def test_get_channel(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json())
channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.id == ch.id
@@ -131,7 +131,7 @@ async def test_set_channel_fees(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json())
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE]
@@ -146,7 +146,7 @@ async def test_set_channel_fees(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json())
channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.fee_ppm == 69
assert channel.fee_base_msat == 42
@@ -162,7 +162,7 @@ async def test_channel_management(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
return parse_obj_as(list[NodeChannel], response.json())
return TypeAdapter(list[NodeChannel]).validate_python(response.json())
data = await get_channels()
close = random.choice(
+1 -56
View File
@@ -1,11 +1,8 @@
import asyncio
from time import time
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import Settings
from lnbits.utils.cache import Cache, Cached
from lnbits.utils.cache import Cache
key = "foo"
value = "bar"
@@ -62,55 +59,3 @@ async def test_cache_coro(cache):
await cache.save_result(test, key="test")
result = await cache.save_result(test, key="test")
assert result == called == 1
def test_cached_older_than():
cached = Cached(value="value", expiry=time() - 5)
assert cached.older_than(1) is True
assert cached.older_than(10) is False
@pytest.mark.anyio
async def test_cache_value_returns_cached_metadata(cache):
cache.set(key, value, expiry=1)
cached = cache.value(key)
assert cached is not None
assert cached.value == value
assert cached.expiry > time()
@pytest.mark.anyio
async def test_cache_pop_expired_returns_default(cache):
cache.set(key, value, expiry=0.01)
await asyncio.sleep(0.02)
assert cache.pop(key, default="fallback") == "fallback"
@pytest.mark.anyio
async def test_invalidate_forever_logs_and_recovers_from_errors(
settings: Settings, mocker: MockerFixture
):
test_cache = Cache(interval=0)
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
original_running = settings.lnbits_running
calls = 0
async def fake_sleep(_interval):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
settings.lnbits_running = False
try:
settings.lnbits_running = True
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
await test_cache.invalidate_forever()
finally:
settings.lnbits_running = original_running
logger_error.assert_called_once_with("Error invalidating cache")
+111
View File
@@ -0,0 +1,111 @@
import pytest
from pydantic import TypeAdapter, ValidationError
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.core.models.payments import PaymentFilters
from lnbits.core.models.users import UserLabel
from lnbits.core.models.wallets import (
Wallet,
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.db import Filter, Page, dict_to_model
from lnbits.nodes.base import NodePayment
def test_user_label_uses_pydantic_v2_pattern_validation():
label = UserLabel(name="label-1", color="#FF00AA")
assert label.name == "label-1"
assert label.color == "#FF00AA"
with pytest.raises(ValidationError):
UserLabel(name="label-1", color="bad-color")
def test_wallet_has_stored_paylinks_field_and_mirrors_it():
source_wallet = Wallet(
id="source-wallet-id",
user="source-user-id",
name="source",
adminkey="admin-key",
inkey="invoice-key",
)
source_wallet.stored_paylinks.links.append(
StoredPayLink(lnurl="lnurl1example", label="saved paylink")
)
shared_wallet = Wallet(
id="shared-wallet-id",
user="shared-user-id",
name="shared",
adminkey="shared-admin-key",
inkey="shared-invoice-key",
)
source_wallet.extra.shared_with.append(
WalletSharePermission(
request_id="share-request-id",
username="shared-user",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
)
shared_wallet.mirror_shared_wallet(source_wallet)
assert shared_wallet.stored_paylinks.links[0].lnurl == "lnurl1example"
assert shared_wallet.stored_paylinks.links[0].label == "saved paylink"
def test_db_dict_to_model_parses_optional_nested_pydantic_v2_models():
ext = dict_to_model(
{
"id": "ext-id",
"name": "Extension",
"version": "1.0.0",
"active": 1,
"meta": (
'{"installed_release": {"name": "Release", "version": "1.0.0", '
'"archive": "https://example.com/release.zip", '
'"source_repo": "lnbits/example"}, "payments": [], '
'"dependencies": [], "featured": false, '
'"has_paid_release": false, "has_free_release": false}'
),
},
InstallableExtension,
)
assert ext.meta is not None
assert ext.meta.installed_release is not None
assert ext.meta.installed_release.source_repo == "lnbits/example"
def test_page_generic_validates_through_pydantic_v2_type_adapter():
page = Page[NodePayment](
data=[
NodePayment(
pending=False,
amount=1,
time=1,
preimage="preimage",
payment_hash="payment-hash",
)
],
total=1,
)
validated = TypeAdapter(Page[NodePayment]).validate_python(
page, from_attributes=True
)
assert validated.total == 1
assert validated.data[0].payment_hash == "payment-hash"
def test_filter_parse_query_uses_pydantic_v2_field_validation():
parsed = Filter.parse_query("amount[eq]", ["42"], PaymentFilters)
assert parsed.field == "amount"
assert parsed.values == {"amount__0": 42}
+1 -90
View File
@@ -1,15 +1,6 @@
from base64 import b64encode
from hashlib import sha256
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.utils.crypto import (
AESCipher,
fake_privkey,
random_secret_and_hash,
verify_preimage,
)
from lnbits.utils.crypto import AESCipher
@pytest.mark.anyio
@@ -27,83 +18,3 @@ async def test_aes_encrypt_decrypt(key):
encrypted_text = aes.encrypt(original_text.encode())
decrypted_text = aes.decrypt(encrypted_text)
assert original_text == decrypted_text
def test_random_secret_and_hash():
secret, payment_hash = random_secret_and_hash(16)
assert len(secret) == 32
assert payment_hash == sha256(bytes.fromhex(secret)).hexdigest()
def test_fake_privkey_is_deterministic():
assert fake_privkey("secret") == fake_privkey("secret")
assert fake_privkey("secret") != fake_privkey("other-secret")
def test_verify_preimage_success_and_failure():
preimage = "00" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
assert verify_preimage(preimage, payment_hash) is True
assert verify_preimage(preimage, "0" * 64) is False
@pytest.mark.anyio
async def test_aes_urlsafe_encrypt_decrypt():
aes = AESCipher("normal_string")
encrypted_text = aes.encrypt(b"url-safe", urlsafe=True)
assert aes.decrypt(encrypted_text, urlsafe=True) == "url-safe"
def test_aes_derive_iv_and_key_requires_eight_byte_salt():
aes = AESCipher("normal_string")
with pytest.raises(ValueError, match="Salt must be 8 bytes"):
aes.derive_iv_and_key(b"short")
def test_aes_decrypt_rejects_invalid_salt_prefix():
aes = AESCipher("normal_string")
encrypted_text = b64encode(b"NotSalted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="Invalid salt."):
aes.decrypt(encrypted_text)
def test_aes_decrypt_raises_for_cipher_errors(mocker: MockerFixture):
aes = AESCipher("normal_string")
fake_cipher = mocker.Mock()
fake_cipher.decrypt.side_effect = RuntimeError("boom")
mocker.patch.object(
aes,
"derive_iv_and_key",
return_value=(b"0" * aes.block_size, b"1" * 32),
)
mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher)
encrypted_text = b64encode(b"Salted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="Could not decrypt payload"):
aes.decrypt(encrypted_text)
def test_aes_decrypt_raises_for_invalid_utf8_output(mocker: MockerFixture):
aes = AESCipher("normal_string")
fake_cipher = mocker.Mock()
fake_cipher.decrypt.return_value = b"\xff\x01"
mocker.patch.object(
aes,
"derive_iv_and_key",
return_value=(b"0" * aes.block_size, b"1" * 32),
)
mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher)
encrypted_text = b64encode(b"Salted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="invalid UTF-8 data"):
aes.decrypt(encrypted_text)
+12 -66
View File
@@ -1,32 +1,7 @@
import pytest
from lnbits.db import Filters
from tests.helpers import DbTestModel
TEST_DB_FETCH_PAGE_ROWS: tuple[dict[str, str], ...] = (
{"id": "1", "name": "Alice", "value": "foo"},
{"id": "2", "name": "Bob", "value": "bar"},
{"id": "3", "name": "Carol", "value": "bar"},
{"id": "4", "name": "Dave", "value": "bar"},
{"id": "5", "name": "Dave", "value": "foo"},
{"id": "6", "name": "Eve", "value": "foo"},
{"id": "7", "name": "Frank", "value": "bar"},
{"id": "8", "name": "Grace", "value": "foo"},
{"id": "9", "name": "Heidi", "value": "bar"},
{"id": "10", "name": "Ivan", "value": "foo"},
{"id": "11", "name": "Judy", "value": "bar"},
{"id": "12", "name": "Mallory", "value": "foo"},
{"id": "13", "name": "Niaj", "value": "bar"},
{"id": "14", "name": "Olivia", "value": "foo"},
{"id": "15", "name": "Peggy", "value": "bar"},
{"id": "16", "name": "Rupert", "value": "foo"},
{"id": "17", "name": "Sybil", "value": "bar"},
{"id": "18", "name": "Trent", "value": "foo"},
{"id": "19", "name": "Victor", "value": "bar"},
{"id": "20", "name": "Walter", "value": "foo"},
{"id": "21", "name": "Zoe", "value": "bar"},
)
@pytest.fixture(scope="session")
async def fetch_page(db):
@@ -38,14 +13,14 @@ async def fetch_page(db):
name TEXT NOT NULL
)
""")
for row in TEST_DB_FETCH_PAGE_ROWS:
await db.execute(
"""
INSERT INTO test_db_fetch_page (id, name, value)
VALUES (:id, :name, :value)
""",
row,
)
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")
@@ -58,35 +33,8 @@ async def test_db_fetch_page_simple(fetch_page, db):
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == Filters().limit
@pytest.mark.anyio
async def test_db_fetch_page_limit_zero_returns_all(fetch_page, db):
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=0),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == len(TEST_DB_FETCH_PAGE_ROWS)
@pytest.mark.anyio
async def test_db_fetch_page_limit(fetch_page, db):
limit = 5
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=limit),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == limit
assert row.total == 5
assert len(row.data) == 5
@pytest.mark.anyio
@@ -97,7 +45,7 @@ async def test_db_fetch_page_group_by(fetch_page, db):
group_by=["name"],
)
assert row
assert row.total == len({test_row["name"] for test_row in TEST_DB_FETCH_PAGE_ROWS})
assert row.total == 4
@pytest.mark.anyio
@@ -108,9 +56,7 @@ async def test_db_fetch_page_group_by_multiple(fetch_page, db):
group_by=["value", "name"],
)
assert row
assert row.total == len(
{(test_row["value"], test_row["name"]) for test_row in TEST_DB_FETCH_PAGE_ROWS}
)
assert row.total == 5
@pytest.mark.anyio
+1 -90
View File
@@ -11,16 +11,7 @@ from pydantic.types import UUID4
from lnbits.core.crud.users import delete_account
from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload
from lnbits.decorators import (
access_token_payload,
check_access_token,
check_admin_ui,
check_extension_builder,
check_first_install,
check_user_exists,
optional_user_id,
)
from lnbits.helpers import create_access_token
from lnbits.decorators import check_user_exists
from lnbits.settings import AuthMethods, Settings, settings
@@ -145,83 +136,3 @@ async def test_check_user_exists_with_user_id_only_not_allowed(user_alan: User):
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Missing user ID or access token."
@pytest.mark.anyio
async def test_check_access_token_prefers_available_source():
assert await check_access_token("header", "cookie", "bearer") == "header"
assert await check_access_token(None, "cookie", "bearer") == "cookie"
assert await check_access_token(None, None, "bearer") == "bearer"
@pytest.mark.anyio
async def test_access_token_payload_success_and_missing(settings: Settings):
token = create_access_token({"sub": "alice", "usr": "user-id"})
payload = await access_token_payload(token)
assert isinstance(payload, AccessTokenPayload)
assert payload.sub == "alice"
assert payload.usr == "user-id"
with pytest.raises(HTTPException, match="Missing access token."):
await access_token_payload(None)
@pytest.mark.anyio
async def test_optional_user_id_uses_user_id_or_access_token(
user_alan: User, settings: Settings
):
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
request = Request({"type": "http", "path": "/wallet", "method": "GET"})
assert (
await optional_user_id(request, access_token=None, usr=UUID4(user_alan.id))
== user_alan.id
)
settings.auth_allowed_methods = []
token = create_access_token({"sub": user_alan.username, "usr": user_alan.id})
assert await optional_user_id(request, access_token=token, usr=None) == user_alan.id
assert await optional_user_id(request, access_token=None, usr=None) is None
@pytest.mark.anyio
async def test_check_admin_ui_and_first_install(settings: Settings):
original_admin_ui = settings.lnbits_admin_ui
original_first_install = settings.first_install
try:
settings.lnbits_admin_ui = False
with pytest.raises(HTTPException, match="Admin UI is disabled."):
await check_admin_ui()
settings.lnbits_admin_ui = True
await check_admin_ui()
settings.first_install = False
with pytest.raises(
HTTPException, match="Super user account has already been configured."
):
await check_first_install()
settings.first_install = True
await check_first_install()
finally:
settings.lnbits_admin_ui = original_admin_ui
settings.first_install = original_first_install
@pytest.mark.anyio
async def test_check_extension_builder_requires_admin_when_disabled_for_users(
settings: Settings, user_alan: User
):
settings.lnbits_extensions_builder_activate_non_admins = False
with pytest.raises(
HTTPException, match="Extension Builder is disabled for non admin users."
):
await check_extension_builder(user_alan)
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)

Some files were not shown because too many files have changed in this diff Show More