Compare commits

..
Author SHA1 Message Date
Arc fa1fec87c7 fundle 2026-05-01 12:32:44 +01:00
Arc 74a1dd5ea6 trans 2026-05-01 12:31:03 +01:00
Arc ef47f1660d make 2026-05-01 12:28:43 +01:00
Arc 4341c329fd extend to repo manifests 2026-05-01 12:16:39 +01:00
Arc fdf3ab2688 init 2026-05-01 12:10:11 +01:00
168 changed files with 27368 additions and 16822 deletions
-4
View File
@@ -19,8 +19,6 @@ AUTH_HTTPS_ONLY=true
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
@@ -119,8 +117,6 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
######################################
+14 -1
View File
@@ -7,6 +7,10 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
@@ -14,6 +18,10 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -105,6 +113,11 @@ jobs:
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
+3 -7
View File
@@ -10,16 +10,15 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
ref: ${{ github.head_ref }}
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: "3.10"
node-version: "24.x"
npm: true
- name: Build and commit bundle (same-repo PR)
if: github.event.pull_request.head.repo.full_name == github.repository
- run: make bundle
- name: Commit and push bundle changes
run: |
make bundle
git config user.name "alan"
git config user.email "alan@lnbits.com"
git add lnbits/static
@@ -28,6 +27,3 @@ jobs:
fi
git commit -m "chore: make bundle [skip ci]"
git push
- name: Check bundle is up-to-date (fork PR)
if: github.event.pull_request.head.repo.full_name != github.repository
run: make checkbundle
-1
View File
@@ -66,7 +66,6 @@ jobs:
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1
DEBUG: true
+11
View File
@@ -12,6 +12,8 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
@@ -20,6 +22,14 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -64,3 +74,4 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+11
View File
@@ -13,6 +13,8 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -21,6 +23,14 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -75,3 +85,4 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-1
View File
@@ -1 +0,0 @@
min-release-age=7
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
+1 -1
View File
@@ -5,7 +5,7 @@
</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)
![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 alt="lnbits_head" src="docs/assets/header.jpg" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
+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
+6 -3
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
@@ -51,7 +51,7 @@ nav_order: 1
sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
@@ -285,7 +285,10 @@ but you can also set the env variables or pass command line arguments:
```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
# Once you have created a user, you can set as the super_user
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
+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
+33 -58
View File
@@ -23,35 +23,29 @@ from lnbits.core.crud import (
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
)
from lnbits.core.services.notifications import (
dispatch_payment_notification,
enqueue_admin_notification,
process_next_notification,
)
from lnbits.core.services.payments import (
check_pending_payments,
fundingsource_invoice_producer,
)
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
notify_server_status,
process_next_audit_entry,
refresh_extension_cache,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.utils.cache import cache
from lnbits.utils.logger import (
configure_logger,
@@ -71,10 +65,9 @@ from .middleware import (
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .task_manager import task_manager
from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI):
@@ -108,9 +101,6 @@ async def startup(app: FastAPI):
# register core routes
init_core_routers(app)
# register llms.txt endpoint for AI agents
create_llms_txt_route(app)
# initialize tasks
register_async_tasks()
@@ -138,7 +128,7 @@ async def shutdown():
settings.lnbits_running = False
# shutdown event
task_manager.cancel_all_tasks()
cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
@@ -206,9 +196,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
@@ -477,42 +464,30 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None:
task_manager.init()
# listen to all incoming payments and dispatch payment notifications
# note: should be the first in task list for a bit quicker notifications
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
# periodic tasks
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
task_manager.create_permanent_task(
check_pending_payments,
interval=settings.lnbits_funding_source_pending_interval_seconds,
create_permanent_task(
run_interval(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
)
task_manager.create_permanent_task(
collect_exchange_rates_data,
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
)
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
task_manager.create_permanent_task(
check_server_balance_against_node,
interval=60 * settings.lnbits_watchdog_interval_minutes,
)
task_manager.create_permanent_task(
notify_server_status,
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
)
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
# permanent tasks run in a loop, will be restarted if they fail
task_manager.create_permanent_task(fundingsource_invoice_producer)
task_manager.create_permanent_task(process_next_notification)
task_manager.create_permanent_task(process_next_audit_entry)
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# server logs for websocket
if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger()
task_manager.create_permanent_task(
server_log_task, name="server_websocket_logger"
)
create_permanent_task(server_log_task)
-2
View File
@@ -6,7 +6,6 @@ from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.blockexplorer_api import blockexplorer_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
@@ -49,7 +48,6 @@ def init_core_routers(app: FastAPI):
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
app.include_router(blockexplorer_router)
__all__ = ["core_app", "core_app_extra", "db"]
+5 -6
View File
@@ -290,10 +290,8 @@ async def create_payment(
webhook=data.webhook,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extension=data.extension,
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
)
await (conn or db).insert("apipayments", payment)
@@ -307,7 +305,7 @@ async def update_payment_checking_id(
await (conn or db).execute(
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
@@ -322,15 +320,13 @@ async def update_payment(
payment: Payment,
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history(
@@ -402,6 +398,7 @@ async def get_payment_count_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
@@ -434,6 +431,7 @@ async def get_daily_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
@@ -483,6 +481,7 @@ async def get_wallets_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
+10 -15
View File
@@ -8,18 +8,11 @@ from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
@@ -76,14 +69,16 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None:
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
-13
View File
@@ -802,16 +802,3 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
-2
View File
@@ -25,7 +25,6 @@ from .payments import (
PaymentState,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
)
from .tinyurl import TinyURL
from .users import (
@@ -91,7 +90,6 @@ __all__ = [
"SimpleStatus",
"TinyURL",
"UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
+70 -23
View File
@@ -6,6 +6,7 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from typing import Any
@@ -20,7 +21,6 @@ from lnbits.helpers import (
version_parse,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache
@@ -43,6 +43,8 @@ class ExplicitRelease(BaseModel):
details_link: str | None
paid_features: str | None
pay_link: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -52,13 +54,14 @@ class GitHubRelease(BaseModel):
id: str
organisation: str
repository: str
admin_only: bool = False
super_user_only: bool = False
class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
featured: list[str] = []
categories: dict[str, list[str]] = {}
class GitHubRepoRelease(BaseModel):
@@ -84,6 +87,8 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -196,6 +201,8 @@ class ExtensionRelease(BaseModel):
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
admin_only: bool = False
super_user_only: bool = False
@property
def archive_url(self) -> str:
@@ -264,6 +271,8 @@ class ExtensionRelease(BaseModel):
paid_features=e.paid_features,
repo=e.repo,
icon=e.icon,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
)
@classmethod
@@ -290,6 +299,8 @@ class ExtensionRelease(BaseModel):
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.admin_only = config.admin_only
release.super_user_only = config.super_user_only
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
@@ -309,6 +320,7 @@ class ExtensionRelease(BaseModel):
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
@@ -333,10 +345,11 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
categories: list[str] = []
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
admin_only: bool = False
super_user_only: bool = False
class InstallableExtension(BaseModel):
@@ -400,6 +413,16 @@ class InstallableExtension(BaseModel):
return False
return self.meta.pay_to_enable.required is True
@property
def is_admin_only(self) -> bool:
return settings.is_admin_extension(self.id) or bool(
self.meta and self.meta.admin_only
)
@property
def is_super_user_only(self) -> bool:
return bool(self.meta and self.meta.super_user_only)
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@@ -455,6 +478,16 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if self.meta:
self.meta.admin_only = config_json.get("admin_only", False)
self.meta.super_user_only = config_json.get("super_user_only", False)
if self.meta.installed_release:
self.meta.installed_release.admin_only = config_json.get(
"admin_only", False
)
self.meta.installed_release.super_user_only = config_json.get(
"super_user_only", False
)
if (
self.meta
@@ -497,6 +530,10 @@ class InstallableExtension(BaseModel):
return
if not release.is_version_compatible:
return
if not self.meta:
self.meta = ExtensionMeta()
self.meta.admin_only = release.admin_only
self.meta.super_user_only = release.super_user_only
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
@@ -559,6 +596,13 @@ class InstallableExtension(BaseModel):
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
admin_only = github_release.admin_only or config.admin_only
super_user_only = github_release.super_user_only or config.super_user_only
latest_extension_release = ExtensionRelease.from_github_release(
source_repo, latest_release
)
latest_extension_release.admin_only = admin_only
latest_extension_release.super_user_only = super_user_only
return InstallableExtension(
id=github_release.id,
name=config.name,
@@ -570,9 +614,9 @@ class InstallableExtension(BaseModel):
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
admin_only=admin_only,
super_user_only=super_user_only,
latest_release=latest_extension_release,
),
)
except Exception as e:
@@ -581,7 +625,12 @@ class InstallableExtension(BaseModel):
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
meta = ExtensionMeta(
archive=e.archive,
dependencies=e.dependencies,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
)
return InstallableExtension(
id=e.id,
name=e.name,
@@ -611,6 +660,8 @@ class InstallableExtension(BaseModel):
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
admin_only=config_json.get("admin_only", False),
super_user_only=config_json.get("super_user_only", False),
installed_release=ExtensionRelease(
name=ext_id,
version=version,
@@ -618,7 +669,9 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
admin_only=config_json.get("admin_only", False),
super_user_only=config_json.get("super_user_only", False),
),
),
)
@@ -642,10 +695,7 @@ class InstallableExtension(BaseModel):
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
task_manager.create_task(
cls._refresh_installable_extensions_cache(),
"refresh_installable_extensions_cache",
)
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@@ -684,11 +734,6 @@ class InstallableExtension(BaseModel):
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
@@ -704,11 +749,6 @@ class InstallableExtension(BaseModel):
ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
except Exception as e:
@@ -733,6 +773,13 @@ class InstallableExtension(BaseModel):
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
for repo_release in repo_releases:
repo_release.admin_only = (
repo_release.admin_only or r.admin_only
)
repo_release.super_user_only = (
repo_release.super_user_only or r.super_user_only
)
extension_releases += repo_releases
for e in manifest.extensions:
+8 -39
View File
@@ -13,7 +13,6 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import (
FiatPaymentStatus,
)
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import (
PaymentStatus,
@@ -35,11 +34,6 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
@@ -54,17 +48,11 @@ class CreatePayment(BaseModel):
amount_msat: int
memo: str
extra: dict | None = {}
extension: str | None = None
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel):
@@ -89,11 +77,6 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data):
super().__init__(**data)
@@ -141,18 +124,22 @@ class Payment(BaseModel):
)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(self) -> PaymentStatus:
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self)
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(self) -> FiatPaymentStatus:
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status
return await check_fiat_status(self)
return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel):
@@ -164,7 +151,6 @@ class PaymentFilters(FilterModel):
"status",
"time",
"labels",
"external_id",
]
__sort_fields__ = [
@@ -175,13 +161,11 @@ class PaymentFilters(FilterModel):
"memo",
"time",
"tag",
"external_id",
]
status: str | None
tag: str | None
checking_id: str | None
external_id: str | None
amount: int
fee: int
memo: str | None
@@ -260,13 +244,11 @@ class CreateInvoice(BaseModel):
)
expiry: int | None = None
extra: dict | None = None
extension: str | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash")
def check_hex(cls, v):
@@ -281,10 +263,6 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported")
return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel):
incoming: int = 0
@@ -323,12 +301,3 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
-12
View File
@@ -1,10 +1,3 @@
from .blockexplorer import (
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
)
from .fiat_providers import check_fiat_status
from .funding_source import (
get_balance_delta,
@@ -63,12 +56,7 @@ __all__ = [
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_fee_estimates",
"fetch_lnurl_pay_request",
"fetch_onchain_balance",
"fetch_recent_blocks",
"fetch_tip",
"fetch_transaction",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
+3 -127
View File
@@ -1,9 +1,7 @@
import base64
import io
from urllib.parse import quote
from uuid import uuid4
import filetype
from fastapi import UploadFile
from loguru import logger
from PIL import Image
@@ -12,48 +10,11 @@ from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
IMAGE_MIME_TYPE_ALIASES = {
"heic": "image/heic",
"heics": "image/heics",
"heif": "image/heif",
"image/jpg": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
}
PIL_IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
}
INLINE_ASSET_MIME_TYPES = {
"image/heic",
"image/heics",
"image/heif",
"image/jpeg",
"image/png",
}
ASSET_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": (
"sandbox; default-src 'none'; script-src 'none'; "
"object-src 'none'; base-uri 'none'"
),
}
THUMBNAIL_FORMAT_MIME_TYPES = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
}
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
@@ -69,26 +30,14 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
stored_mime_type = detect_image_mime_type(contents)
if stored_mime_type != content_type:
logger.warning(
"Image MIME type mismatch: declared={}, detected={}",
content_type,
stored_mime_type,
)
raise ValueError(
"Image file content does not match declared file type. "
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=stored_mime_type,
mime_type=file.content_type,
is_public=is_public,
name=filename,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -102,79 +51,6 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
return asset
def normalize_asset_mime_type(content_type: str) -> str:
content_type = content_type.split(";", 1)[0].strip().lower()
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
def normalize_media_type(media_type: str) -> str:
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
def thumbnail_media_type() -> str:
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
def content_disposition(disposition: str, filename: str) -> str:
safe_filename = filename or "unnamed"
quoted_filename = quote(safe_filename, safe="")
if quoted_filename == safe_filename:
return f'{disposition}; filename="{safe_filename}"'
return f"{disposition}; filename*=utf-8''{quoted_filename}"
def allowed_asset_mime_types() -> set[str]:
return {
mime_type
for mime_type in (
normalize_asset_mime_type(mime_type)
for mime_type in settings.lnbits_assets_allowed_mime_types
)
if mime_type.startswith("image/")
}
def detect_image_mime_type(contents: bytes) -> str:
kind = filetype.guess(contents)
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
verify_pil_image(contents, mime_type)
return mime_type
if mime_type and mime_type.startswith("image/"):
return mime_type
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if not mime_type:
raise ValueError("Image file content does not match declared file type.")
return mime_type
def verify_pil_image(contents: bytes, mime_type: str) -> None:
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if detected_mime_type != mime_type:
raise ValueError("Image file content does not match declared file type.")
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
image = Image.open(io.BytesIO(contents))
-97
View File
@@ -1,97 +0,0 @@
import asyncio
from lnbits.settings import settings
from lnbits.task_manager import OnchainAddressEvent
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
Balance,
BlockHeader,
BlockInfo,
ElectrumClient,
FeeResponse,
Transaction,
network_from_name,
parse_block_header,
parse_raw_tx,
scripthash_from_address,
)
def _client() -> ElectrumClient:
return ElectrumClient(
settings.lnbits_blockexplorer_electrum_url,
network=network_from_name(settings.lnbits_blockexplorer_network),
)
async def fetch_recent_blocks(count: int = 5) -> list[BlockInfo]:
async with _client() as c:
tip = await c.get_tip()
start = max(0, tip.height - count + 1)
headers = await c.get_block_headers(start, tip.height - start + 1)
raw = bytes.fromhex(headers.hex)
blocks = [
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
for i in range(headers.count)
]
return list(reversed(blocks))
async def fetch_tip() -> BlockHeader:
async with _client() as c:
return await c.get_tip()
async def fetch_fee_estimates() -> FeeResponse:
async with _client() as c:
estimates_raw = await asyncio.gather(
c.estimate_fee(1),
c.estimate_fee(3),
c.estimate_fee(6),
c.estimate_fee(144),
)
histogram = await c.fee_histogram()
estimates = {
str(blocks): fee
for blocks, fee in zip([1, 3, 6, 144], estimates_raw, strict=False)
if fee >= 0
}
return FeeResponse(estimates=estimates, histogram=histogram)
async def fetch_transaction(txid: str) -> Transaction:
async with _client() as c:
raw_hex = await c.get_transaction(txid)
return parse_raw_tx(raw_hex, network=c.network)
async def fetch_onchain_balance(onchain_address: str) -> AddressResponse:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
balance_res, history_res = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
return_exceptions=True,
)
if isinstance(balance_res, BaseException):
raise balance_res
history = [] if isinstance(history_res, BaseException) else history_res
history_error = str(history_res) if isinstance(history_res, BaseException) else None
return AddressResponse(
balance=balance_res, history=history, history_error=history_error
)
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
return await client.listunspent(scripthash)
def address_event_to_response(event: OnchainAddressEvent) -> AddressResponse:
return AddressResponse(
balance=Balance(confirmed=event.confirmed, unconfirmed=event.unconfirmed),
history=event.history,
history_error=event.history_error,
)
+11 -84
View File
@@ -2,13 +2,12 @@ import hashlib
import hmac
import json
import time
from base64 import b64encode
import httpx
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, update_payment
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
@@ -20,7 +19,6 @@ from lnbits.fiat.base import (
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
async def handle_fiat_payment_confirmation(
@@ -37,7 +35,9 @@ async def handle_fiat_payment_confirmation(
logger.warning(e)
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
@@ -57,11 +57,14 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
payment.status = PaymentState.SUCCESS.value
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
task_manager.internal_invoice_queue.put_nowait(payment)
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
return fiat_status
@@ -166,82 +169,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
raise ValueError("PayPal webhook cannot be verified.") from exc
def check_square_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
notification_url: str | None,
):
if not sig_header:
logger.warning("Square signature header is missing.")
raise ValueError("Square signature header is missing.")
if not secret:
logger.warning("Square webhook signature key is not set.")
raise ValueError("Square webhook cannot be verified.")
if not notification_url:
logger.warning("Square webhook notification URL is not set.")
raise ValueError("Square webhook cannot be verified.")
signed_payload = notification_url.encode() + payload
computed_signature = b64encode(
hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).digest()
).decode()
if hmac.compare_digest(computed_signature, sig_header) is not True:
logger.warning("Square signature verification failed.")
raise ValueError("Square signature verification failed.")
def check_revolut_signature(
payload: bytes,
sig_header: str | None,
timestamp_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Revolut signature header is missing.")
raise ValueError("Revolut signature header is missing.")
if not timestamp_header:
logger.warning("Revolut timestamp header is missing.")
raise ValueError("Revolut timestamp header is missing.")
if not secret:
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
digest = hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"v1={digest}"
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
-2
View File
@@ -66,8 +66,6 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
+6 -44
View File
@@ -74,7 +74,7 @@ async def send_admin_notification(
message: str,
message_type: str | None = None,
) -> None:
return await send_notification_in_background(
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
@@ -97,7 +97,7 @@ async def send_user_notification(
if user_notifications.nostr_identifier
else []
)
return await send_notification_in_background(
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
@@ -222,29 +222,12 @@ async def send_email(
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email
def _send() -> bool:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
try:
return await asyncio.to_thread(_send)
except Exception as e:
logger.warning(f"Sending Email failed. {e!s}")
return False
async def dispatch_payment_notification(payment: Payment) -> None:
"""
This worker dispatches the payment notifications.
"""
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def dispatch_webhook(payment: Payment):
"""
@@ -311,27 +294,6 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
logger.warning(f"Error sending payment notification: {e}")
async def send_notification_in_background(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: str | None = None,
):
try:
create_task(
send_notification(
telegram_chat_id,
nostr_identifiers,
email_addresses,
message,
message_type,
)
)
except Exception as e:
logger.warning(f"Error sending notification in background: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json())
+39 -59
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.task_manager import task_manager
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
@@ -64,7 +64,6 @@ async def pay_invoice(
description: str = "",
tag: str = "",
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@@ -98,7 +97,6 @@ async def pay_invoice(
memo=description or invoice.description or "",
extra=extra,
labels=labels,
external_id=external_id,
)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -171,15 +169,15 @@ async def create_fiat_invoice(
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# TODO: move to payment
# todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
internal_payment = await update_payment(
internal_payment, new_checking_id, conn=conn
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
return internal_payment
@@ -215,12 +213,10 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
extension=data.extension,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
external_id=data.external_id,
conn=conn,
)
@@ -261,9 +257,7 @@ async def create_invoice(
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
extension: str | None = None,
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -345,11 +339,9 @@ async def create_invoice(
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
extension=extension,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
external_id=external_id,
)
payment = await create_payment(
@@ -377,7 +369,7 @@ async def update_pending_payment(
status = await check_payment_status(payment)
if status.failed:
payment.status = PaymentState.FAILED
payment = await update_payment(payment, conn=conn)
await update_payment(payment, conn=conn)
elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn)
return payment
@@ -517,7 +509,9 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
task_manager.internal_invoice_queue.put_nowait(payment)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
async def check_wallet_limits(
@@ -631,14 +625,18 @@ async def check_transaction_status(
return await check_payment_status(payment)
async def check_payment_status(payment: Payment) -> PaymentStatus:
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(payment)
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
@@ -780,16 +778,13 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(
wallet.id, payment, conn=conn
) # notify the sender
await _send_payment_notification_in_background(
internal_payment.wallet_id, internal_payment, conn=conn
) # notify the receiver
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
# notify receiver asynchronously (extension listeners)
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
task_manager.internal_invoice_queue.put_nowait(internal_payment)
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
@@ -826,15 +821,16 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
task = task_manager.create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
f"fundingsource_pay_invoice_{checking_id}",
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
# make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try:
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
payment_response = await asyncio.wait_for(task, timeout=wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(
@@ -875,7 +871,7 @@ async def update_payment_success_status(
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
payment = await update_payment(payment, conn=conn)
await update_payment(payment, conn=conn)
return payment
@@ -1078,44 +1074,28 @@ async def _send_payment_notification_in_background(
send_payment_notification_in_background(wallet, payment)
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment from paid_invoices_stream()
Checks its status, updates its status and returns it.
returns None if no incoming payment was found or the status is not successful
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 incoming payment found for '{checking_id}'.")
logger.warning(f"No payment found for '{checking_id}'.")
return None
status = await check_payment_status(payment)
if not status.success:
logger.error(
"Unexpected status response from paid_invoices_stream. Skipping update."
)
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
payment = await update_payment(payment)
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
async def fundingsource_invoice_producer() -> None:
"""
will collect all invoices that come directly from the backend wallet.
Called registered in the app startup sequence and run by taskmanager.
"""
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_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
task_manager.invoice_queue.put_nowait(payment)
+118 -47
View File
@@ -2,43 +2,76 @@ import asyncio
from loguru import logger
from lnbits.core.crud import create_audit_entry
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import get_balance_delta
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def process_next_audit_entry() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
data = await audit_queue.get()
await create_audit_entry(data)
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0:
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions(
post_refresh_cache=True
)
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
async def refresh_extension_cache() -> None:
# only refreshes every 10 minutes
await InstallableExtension.get_installable_extensions()
async def notify_server_status() -> None:
async def _notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count()
payments = await get_payments_status_count()
status = await get_balance_delta()
values = {
"up_time": settings.lnbits_server_up_time,
@@ -55,38 +88,76 @@ async def notify_server_status() -> None:
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def wait_for_audit_data() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def wait_notification_messages() -> None:
while settings.lnbits_running:
try:
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None:
"""
Collect exchange rates data. Used for monitoring only.
"""
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
try:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
cache.set(
f"btc-price-{currency}",
price,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
else:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
sleep_time = 60
await asyncio.sleep(sleep_time)
+5 -4
View File
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.task_manager import PublicTask, task_manager
from lnbits.tasks import invoice_listeners
from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,10 +44,11 @@ async def api_auditor():
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
)
async def api_monitor() -> list[PublicTask]:
return task_manager.get_public_tasks()
async def api_monitor():
return {
"invoice_listeners": list(invoice_listeners.keys()),
}
@admin_router.get(
+9 -25
View File
@@ -16,14 +16,7 @@ from lnbits.core.crud.assets import (
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import (
ASSET_SECURITY_HEADERS,
INLINE_ASSET_MIME_TYPES,
content_disposition,
create_user_asset,
normalize_media_type,
thumbnail_media_type,
)
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
@@ -82,7 +75,11 @@ async def api_get_asset_data(
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(asset.data, asset.mime_type, asset.name)
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=thumbnail_media_type(),
filename=asset_info.name,
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@@ -175,16 +172,3 @@ async def api_delete_asset(
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
media_type = normalize_media_type(media_type)
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
return Response(
content=content,
media_type=media_type,
headers={
**ASSET_SECURITY_HEADERS,
"Content-Disposition": content_disposition(disposition, filename),
},
)
+2 -12
View File
@@ -36,7 +36,6 @@ from lnbits.decorators import (
check_account_exists,
check_admin,
check_user_exists,
optional_user_id,
)
from lnbits.helpers import (
create_access_token,
@@ -321,10 +320,7 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request,
provider: str,
user_id: str | None,
auth_user_id: str | None = Depends(optional_user_id),
request: Request, provider: str, user_id: str | None = None
):
provider_sso = _new_sso(provider)
if not provider_sso:
@@ -332,8 +328,6 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.",
)
if user_id and user_id != auth_user_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso:
@@ -354,11 +348,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
userinfo = await provider_sso.verify_and_process(request)
if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
if provider_sso.state is None or provider_sso.state == "null":
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
-170
View File
@@ -1,170 +0,0 @@
import asyncio
from http import HTTPStatus
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from pydantic.types import UUID4
from lnbits.core.services.blockexplorer import (
address_event_to_response,
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
fetch_utxos,
)
from lnbits.decorators import check_access_token, check_user_exists
from lnbits.settings import settings
from lnbits.task_manager import (
OnchainAddressEvent,
OnchainTxEvent,
relay_ws_queue,
task_manager,
)
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumError,
FeeResponse,
Transaction,
scripthash_from_address,
)
blockexplorer_router = APIRouter(
tags=["Block Explorer"],
prefix="/blockexplorer/api/v1",
)
def _check_enabled() -> None:
if not settings.lnbits_blockexplorer_enabled:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Block explorer is not enabled.",
)
async def _check_api_access(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> None:
_check_enabled()
if not settings.lnbits_blockexplorer_public_api:
await check_user_exists(r, access_token, usr)
# ---- REST ----
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
async def api_blocks() -> list[BlockInfo]:
try:
return await fetch_recent_blocks()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tip", dependencies=[Depends(_check_api_access)])
async def api_tip() -> BlockHeader:
try:
return await fetch_tip()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/fees", dependencies=[Depends(_check_api_access)])
async def api_fees() -> FeeResponse:
try:
return await fetch_fee_estimates()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tx/{txid}", dependencies=[Depends(_check_api_access)])
async def api_tx(txid: str) -> Transaction:
try:
return await fetch_transaction(txid)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get(
"/address/{address}", dependencies=[Depends(_check_api_access)]
)
async def api_address(address: str) -> AddressResponse:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_onchain_balance(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
async def api_utxos(address: str) -> list[UTXO]:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_utxos(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
# ---- WebSocket ----
@blockexplorer_router.websocket("/ws/blocks")
async def ws_blocks(websocket: WebSocket) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
task_manager.register_ws_block_queue(queue)
try:
await relay_ws_queue(websocket, queue)
finally:
task_manager.unregister_ws_block_queue(queue)
@blockexplorer_router.websocket("/ws/address/{address}")
async def ws_address(websocket: WebSocket, address: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
try:
task_manager.register_ws_address_queue(address, queue)
except ValueError as e:
await websocket.close(code=1008, reason=str(e))
return
try:
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
finally:
task_manager.unregister_ws_address_queue(address, queue)
@blockexplorer_router.websocket("/ws/tx/{txid}")
async def ws_tx(websocket: WebSocket, txid: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
task_manager.register_ws_tx_queue(txid, queue)
try:
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
finally:
task_manager.unregister_ws_tx_queue(txid, queue)
+1 -428
View File
@@ -4,30 +4,17 @@ from fastapi import APIRouter, Request
from loguru import logger
from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import (
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -63,41 +50,6 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "square":
payload = await request.body()
sig_header = request.headers.get("x-square-hmacsha256-signature")
check_square_signature(
payload,
sig_header,
settings.square_webhook_signature_key,
settings.square_payment_webhook_url,
)
event = await request.json()
await handle_square_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "revolut":
payload = await request.body()
sig_header = request.headers.get("Revolut-Signature")
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
check_revolut_signature(
payload,
sig_header,
timestamp_header,
settings.revolut_webhook_signing_secret,
)
event = await request.json()
await handle_revolut_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
@@ -328,382 +280,3 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions()
async def handle_square_event(event: dict):
event_id = event.get("event_id") or event.get("id", "")
event_type = event.get("type", "")
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
if event_type == "payment.updated":
await _handle_square_payment_event(event)
return
if event_type == "invoice.payment_made":
await _handle_square_invoice_payment_made(event)
return
logger.warning(f"Unhandled Square event type: '{event_type}'.")
async def handle_revolut_event(event: dict):
event_type = event.get("event", "")
order_id = event.get("order_id")
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
if not order_id:
logger.warning("Revolut event missing order_id.")
return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if payment:
await check_fiat_status(payment)
return
if event_type == "ORDER_COMPLETED":
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
return
if event_type == "SUBSCRIPTION_INITIATED":
logger.info("Revolut subscription initiated event received.")
return
if event_type in [
"SUBSCRIPTION_CANCELLED",
"SUBSCRIPTION_FINISHED",
"SUBSCRIPTION_OVERDUE",
]:
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
return
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict,
fiat_provider: RevolutWallet,
order_id: str | None = None,
order: dict | None = None,
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return
reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference:
logger.warning("Revolut subscription event missing LNbits metadata.")
return
if not order_id:
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment:
if existing_payment.external_id != subscription_id:
existing_payment.external_id = subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
if not order:
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency:
raise ValueError("Revolut subscription order missing amount or currency.")
extra = {
**(reference.extra or {}),
"subscription_request_id": subscription_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
"checking_id": f"order_{order_id}",
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
amount_minor=amount_minor,
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
order_type = (order.get("type") or "").lower()
order_state = (order.get("state") or "").upper()
if order_type != "payment" or order_state != "COMPLETED":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription(
subscription, fiat_provider, order_id=order_id, order=order
)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency,
amount=amount,
memo=memo,
extra=extra,
internal=True,
external_id=subscription_id,
),
)
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut"
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict):
payment = _square_extract_payment(event)
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if payment_options.wallet_id:
if not _square_payment_is_completed(payment):
logger.debug("Square subscription payment is not completed yet.")
return
await _handle_square_subscription_payment(payment, payment_options)
return
order_id = payment.get("order_id")
if not order_id:
logger.warning("Square payment event missing order_id.")
return
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
if not lnbits_payment:
logger.warning(f"No payment found for Square order: '{order_id}'.")
return
await check_fiat_status(lnbits_payment)
async def _handle_square_invoice_payment_made(event: dict):
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
order_id = invoice.get("order_id")
if not order_id:
logger.warning("Square invoice.payment_made event missing order_id.")
return
subscription_id = invoice.get("subscription_id")
fiat_provider = await get_fiat_provider("square")
if not isinstance(fiat_provider, SquareWallet):
logger.warning("Square fiat provider is not configured.")
return
payment = await fiat_provider.get_payment_for_order(order_id)
if not payment:
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
return
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
else None
)
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
logger.warning("Square subscription payment missing LNbits metadata.")
return
await _handle_square_subscription_payment(
payment,
payment_options,
invoice.get("public_url") or "",
square_subscription_id=subscription_id,
)
async def _handle_square_subscription_payment(
payment: dict,
payment_options: FiatSubscriptionPaymentOptions,
payment_request: str = "",
square_subscription_id: str | None = None,
):
amount_money = payment.get("amount_money") or {}
amount = amount_money.get("amount")
currency = (amount_money.get("currency") or "").upper()
payment_id = payment.get("id")
if amount is None or not currency or not payment_id:
raise ValueError("Square subscription payment event missing payment amount.")
wallet_id = payment_options.wallet_id
if not wallet_id:
raise ValueError("Square subscription payment event missing wallet_id.")
checking_id = f"payment_{payment_id}"
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
if existing_payment:
if (
square_subscription_id
and existing_payment.external_id != square_subscription_id
):
existing_payment.external_id = square_subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
square_subscription_id = square_subscription_id or (
payment_options.extra or {}
).get("square_subscription_id")
extra = {
**(payment_options.extra or {}),
"subscription_request_id": payment_options.subscription_request_id,
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": checking_id,
"payment_request": payment_request,
},
}
lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount / 100,
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
await check_fiat_status(lnbits_payment)
def _square_payment_options_from_payment(
payment: Payment,
) -> FiatSubscriptionPaymentOptions:
extra = payment.extra or {}
return FiatSubscriptionPaymentOptions(
wallet_id=payment.wallet_id,
tag=extra.get("tag") or payment.tag,
subscription_request_id=extra.get("subscription_request_id"),
extra=extra,
memo=payment.memo,
)
def _square_extract_payment(event: dict) -> dict:
event_object = event.get("data", {}).get("object", {})
return event_object.get("payment") or event_object
def _square_payment_is_completed(payment: dict) -> bool:
return (payment.get("status") or "").upper() == "COMPLETED"
def _square_payment_note(payment: dict) -> str:
return payment.get("note") or payment.get("payment_note") or ""
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
if not isinstance(meta, list):
return FiatSubscriptionPaymentOptions()
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+24 -3
View File
@@ -79,7 +79,11 @@ async def api_install_extension(data: CreateExtension):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
ext_meta = ExtensionMeta(
installed_release=release,
admin_only=release.admin_only,
super_user_only=release.super_user_only,
)
ext_info = InstallableExtension(
id=data.ext_id,
name=data.ext_id,
@@ -176,6 +180,19 @@ async def api_update_pay_to_enable(
)
def _check_enable_extension_access(
ext_id: str, ext: InstallableExtension, account_id: AccountId
) -> None:
if ext.is_super_user_only and not settings.is_super_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
if ext.is_admin_only and not settings.is_admin_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
@extension_router.put("/{ext_id}/enable")
async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
@@ -191,6 +208,7 @@ async def api_enable_extension(
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
_check_enable_extension_access(ext_id, ext, account_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
@@ -292,6 +310,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(
@@ -463,6 +482,8 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
"admin_only": config.admin_only,
"super_user_only": config.super_user_only,
}
except Exception as exc:
raise HTTPException(
@@ -566,14 +587,14 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isAdminOnly": ext.is_admin_only,
"isSuperUserOnly": ext.is_super_user_only,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
+1 -67
View File
@@ -2,35 +2,17 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services import update_cached_settings
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
class RevolutCreateWebhook(BaseModel):
url: str
endpoint: str | None = None
api_secret_key: str | None = None
api_version: str | None = None
class RevolutCreateWebhookResponse(BaseModel):
id: str | None = None
url: str
events: list[str] = []
signing_secret: str
already_exists: bool = False
@fiat_router.put(
"/check/{provider}",
status_code=HTTPStatus.OK,
@@ -40,54 +22,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
@fiat_router.post(
"/revolut/webhook",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_create_revolut_webhook(
data: RevolutCreateWebhook,
) -> RevolutCreateWebhookResponse:
try:
webhook = await RevolutWallet.create_webhook(
url=data.url,
endpoint=data.endpoint,
api_secret_key=data.api_secret_key,
api_version=data.api_version,
)
except ValueError as exc:
logger.warning(exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=500, detail="Failed to create Revolut webhook."
) from exc
signing_secret = webhook.get("signing_secret")
webhook_url = webhook.get("url") or data.url
if not signing_secret:
raise HTTPException(
status_code=502, detail="Revolut returned no webhook signing secret."
)
updated_settings = {
"revolut_payment_webhook_url": webhook_url,
"revolut_webhook_signing_secret": signing_secret,
}
for key, value in updated_settings.items():
await set_settings_field(key, value)
update_cached_settings(updated_settings)
return RevolutCreateWebhookResponse(
id=webhook.get("id"),
url=webhook_url,
events=webhook.get("events") or [],
signing_secret=signing_secret,
already_exists=webhook.get("already_exists", False),
)
@fiat_router.post(
"/{provider}/subscription",
status_code=HTTPStatus.OK,
-1
View File
@@ -185,7 +185,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/blockexplorer")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
-34
View File
@@ -34,7 +34,6 @@ from lnbits.core.models import (
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
UpdatePaymentExtra,
)
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId
@@ -264,7 +263,6 @@ async def api_payments_create(
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
external_id=invoice_data.external_id,
)
return payment
@@ -298,38 +296,6 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
+2 -4
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' format for the database URL."
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
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}"
+14 -1
View File
@@ -14,6 +14,7 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_installed_extension,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
@@ -424,7 +425,19 @@ async def check_user_extension_access(
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
"""
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
ext = await get_installed_extension(ext_id, conn=conn)
is_admin_only = settings.is_admin_extension(ext_id) or bool(
ext and ext.meta and ext.meta.admin_only
)
is_super_user_only = bool(ext and ext.meta and ext.meta.super_user_only)
if is_super_user_only and not settings.is_super_user(user_id):
return SimpleStatus(
success=False,
message=f"User not authorized for extension '{ext_id}'.",
)
if is_admin_only and not settings.is_admin_user(user_id):
return SimpleStatus(
success=False, message=f"User not authorized for extension '{ext_id}'."
)
-6
View File
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet",
]
+3 -7
View File
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.",
)
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field(
default=None,
description="Payments created by the recurring subscription"
@@ -131,15 +127,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True # type: ignore[reportIncompatibleVariableOverride]
paid = True
class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False # type: ignore[reportIncompatibleVariableOverride]
paid = False
class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None # type: ignore[reportIncompatibleVariableOverride]
paid = None
class FiatProvider(ABC):
-646
View File
@@ -1,646 +0,0 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class RevolutCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class RevolutCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: RevolutCheckoutOptions | None = None
class RevolutSubscriptionReference(BaseModel):
wallet_id: str
tag: str | None = None
subscription_request_id: str | None = None
extra: dict[str, Any] | None = None
memo: str | None = None
REVOLUT_WEBHOOK_EVENTS = [
"ORDER_AUTHORISED",
"ORDER_COMPLETED",
"SUBSCRIPTION_INITIATED",
]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant"""
def __init__(self):
logger.debug("Initializing RevolutWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.revolut_api_endpoint:
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
if not settings.revolut_api_secret_key:
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
"Revolut-Api-Version": settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("RevolutWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Revolut wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
)
payload = {
"amount": amount_minor,
"currency": currency.upper(),
"description": checkout.description or memo or "LNbits Invoice",
"redirect_url": success_url,
"metadata": {
**checkout.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
},
}
try:
r = await self.client.post(
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
checkout_url = data.get("checkout_url")
if not order_id or not checkout_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=checkout_url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if quantity != 1:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions do not support quantity.",
)
wallet_id = payment_options.wallet_id
if not wallet_id:
return FiatSubscriptionResponse(
ok=False, error_message="Wallet ID is required."
)
extra = payment_options.extra or {}
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
reference = RevolutSubscriptionReference(
wallet_id=wallet_id,
tag=payment_options.tag,
subscription_request_id=payment_options.subscription_request_id,
extra=extra,
memo=payment_options.memo,
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
),
}
if extra.get("trial_duration"):
payload["trial_duration"] = extra["trial_duration"]
headers = {
**self.headers,
"Idempotency-Key": payment_options.subscription_request_id,
}
try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post(
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
revolut_subscription_id = data.get("id")
setup_order_id = data.get("setup_order_id")
if not revolut_subscription_id or not setup_order_id:
return FiatSubscriptionResponse(
ok=False,
error_message=(
"Server error: missing subscription id or setup order id"
),
)
setup_order = await self.get_order(setup_order_id)
checkout_url = setup_order.get("checkout_url")
if not checkout_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing setup checkout url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=revolut_subscription_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
subscription = await self.get_subscription(subscription_id)
reference = self.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference or reference.wallet_id != correlation_id:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not found."
)
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Revolut does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
order_id = self._normalize_revolut_id(checking_id)
return self._status_from_order(await self.get_order(order_id))
except Exception as exc:
logger.debug(f"Error getting Revolut invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Revolut does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Revolut does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _normalize_revolut_id(self, checking_id: str) -> str:
value = (
checking_id.replace("fiat_revolut_", "", 1)
if checking_id.startswith("fiat_revolut_")
else checking_id
)
return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription_cycle(
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
@classmethod
async def create_webhook(
cls,
url: str,
endpoint: str | None = None,
api_secret_key: str | None = None,
api_version: str | None = None,
) -> dict[str, Any]:
if not url:
raise ValueError("Missing Revolut webhook URL.")
cls._validate_webhook_url(url)
if not endpoint and not settings.revolut_api_endpoint:
raise ValueError("Missing Revolut API endpoint.")
if not api_secret_key and not settings.revolut_api_secret_key:
raise ValueError("Missing Revolut API secret key.")
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
secret_key = api_secret_key or settings.revolut_api_secret_key
headers = {
"Authorization": f"Bearer {secret_key}",
"Revolut-Api-Version": api_version or settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
webhooks = await cls._list_webhooks(client)
existing = await cls._get_existing_webhook(client, webhooks, url)
if existing:
existing["already_exists"] = True
return existing
response = await client.post(
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
@classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
for field in ["webhooks", "data", "items"]:
if isinstance(data.get(field), list):
return data[field]
return []
@classmethod
async def _get_existing_webhook(
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
) -> dict[str, Any] | None:
for webhook in webhooks:
if cls._normalize_webhook_url(webhook.get("url")) != (
cls._normalize_webhook_url(url)
):
continue
webhook_id = webhook.get("id")
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
webhook = response.json()
events = set(webhook.get("events") or [])
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
if missing_events:
raise ValueError(
"A Revolut webhook already exists for this URL, but it is "
f"missing required events: {', '.join(sorted(missing_events))}."
)
if not webhook.get("signing_secret"):
raise ValueError(
"A Revolut webhook already exists for this URL, but Revolut "
"did not return a signing secret."
)
return webhook
return None
@classmethod
def _normalize_webhook_url(cls, url: str | None) -> str:
return (url or "").strip().rstrip("/")
@classmethod
def _validate_webhook_url(cls, url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if parsed.scheme not in ["http", "https"] or not hostname:
raise ValueError("Revolut webhook URL must be a clearnet URL.")
host = hostname.lower()
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
if host.endswith(".local") or host.endswith(".onion"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None:
try:
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Revolut options: {e}")
return None
def _serialize_subscription_reference(
self, reference: RevolutSubscriptionReference
) -> str:
payload = reference.dict(exclude_none=True)
serialized = json.dumps(payload, separators=(",", ":"))
if len(serialized) > 1024:
raise ValueError("Revolut subscription external_reference is too long.")
return serialized
def deserialize_subscription_reference(
self, external_reference: str | None
) -> RevolutSubscriptionReference | None:
if not external_reference:
return None
try:
return RevolutSubscriptionReference.parse_obj(
json.loads(external_reference)
)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning(exc)
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.revolut_api_endpoint),
str(settings.revolut_api_secret_key),
str(settings.revolut_api_version),
str(settings.revolut_webhook_signing_secret),
]
)
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
-620
View File
@@ -1,620 +0,0 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "subscription"]
class SquareCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class SquareSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class SquareCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: SquareCheckoutOptions | None = None
subscription: SquareSubscriptionOptions | None = None
class SquareSubscriptionCheckoutInfo(BaseModel):
plan_variation_id: str
price_money: dict[str, Any]
class SquareWallet(FiatProvider):
"""https://developer.squareup.com/reference/square"""
def __init__(self):
logger.debug("Initializing SquareWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.square_api_endpoint:
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
if not settings.square_access_token:
raise ValueError("Cannot initialize SquareWallet: missing access token.")
if not settings.square_location_id:
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
self.location_id = settings.square_location_id
self.headers = {
"Authorization": f"Bearer {settings.square_access_token}",
"Square-Version": settings.square_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("SquareWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Square wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
if opts.fiat_method == "subscription":
return self._create_subscription_invoice(opts.subscription)
return await self._create_checkout_invoice(
amount=amount,
payment_hash=payment_hash,
currency=currency,
opts=opts,
memo=memo,
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if settings.lnbits_running:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not supported for Square."
)
success_url = (
payment_options.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
checkout_info = await self._get_subscription_checkout_info(subscription_id)
metadata = self._serialize_metadata(payment_options)
payload = {
"idempotency_key": payment_options.subscription_request_id,
"description": metadata,
"quick_pay": {
"name": (payment_options.memo or "LNbits Subscription")[:255],
"price_money": checkout_info.price_money,
"location_id": self.location_id,
},
"checkout_options": {
"redirect_url": success_url,
"subscription_plan_id": checkout_info.plan_variation_id,
},
"payment_note": metadata,
}
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
url = payment_link.get("url")
if not url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
square_subscription_id = await self._get_square_subscription_id(
subscription_id, correlation_id
)
r = await self.client.post(
f"/v2/subscriptions/{square_subscription_id}/cancel"
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Square does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
square_id = self._normalize_square_id(checking_id)
if square_id.startswith("payment_"):
payment_id = square_id.replace("payment_", "", 1)
return await self._get_payment_status(payment_id)
order_id = (
square_id.replace("order_", "", 1)
if square_id.startswith("order_")
else square_id
)
return await self._get_order_status(order_id)
except Exception as exc:
logger.debug(f"Error getting Square invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Square does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Square does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if payment_id:
return await self._get_payment_status(payment_id)
if (order.get("state") or "").upper() == "CANCELED":
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/orders/{order_id}")
r.raise_for_status()
return r.json().get("order") or {}
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if not payment_id:
return None
return await self._get_payment(payment_id)
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
tenders = order.get("tenders") or []
for tender in tenders:
payment_id = tender.get("payment_id")
if payment_id:
return payment_id
return None
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
return self._status_from_payment(await self._get_payment(payment_id))
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/payments/{payment_id}")
r.raise_for_status()
return r.json().get("payment") or {}
async def _get_subscription_checkout_info(
self, subscription_plan_id: str
) -> SquareSubscriptionCheckoutInfo:
catalog_object = await self._get_catalog_object(subscription_plan_id)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
return await self._get_plan_checkout_info(catalog_object)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
price_money = await self._get_subscription_price_money(
catalog_object,
)
plan_variation_id = catalog_object.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
raise ValueError(
"Square subscription ID must be a plan ID or plan variation ID."
)
async def _get_plan_checkout_info(
self, catalog_object: dict[str, Any]
) -> SquareSubscriptionCheckoutInfo:
plan_data = catalog_object.get("subscription_plan_data") or {}
plan_variations = plan_data.get("subscription_plan_variations") or []
eligible_item_ids = plan_data.get("eligible_item_ids") or []
plan_variation = next(
(
variation
for variation in plan_variations
if not variation.get("is_deleted")
),
None,
)
if not plan_variation:
raise ValueError("Square subscription plan is missing a variation.")
price_money = await self._get_subscription_price_money(
plan_variation,
eligible_item_ids=eligible_item_ids,
)
plan_variation_id = plan_variation.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/catalog/object/{object_id}")
r.raise_for_status()
return r.json().get("object") or {}
async def _get_subscription_price_money(
self,
plan_variation: dict[str, Any],
eligible_item_ids: list[str] | None = None,
) -> dict[str, Any]:
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
phases = variation_data.get("phases") or []
for phase in phases:
pricing = phase.get("pricing") or {}
price_money = pricing.get("price_money") or phase.get(
"recurring_price_money"
)
parsed_price_money = self._parse_price_money(price_money)
if parsed_price_money:
return parsed_price_money
if pricing.get("type") == "RELATIVE":
return await self._get_relative_subscription_price_money(
eligible_item_ids or []
)
raise ValueError("Square subscription plan variation is missing price_money.")
async def _get_relative_subscription_price_money(
self, eligible_item_ids: list[str]
) -> dict[str, Any]:
if len(eligible_item_ids) != 1:
raise ValueError(
"Square relative subscription plan must have exactly one item."
)
item = await self._get_catalog_object(eligible_item_ids[0])
item_variations: list[dict[str, Any]] = []
if item.get("type") == "ITEM":
item_variations = (item.get("item_data") or {}).get("variations") or []
elif item.get("type") == "ITEM_VARIATION":
item_variations = [item]
item_variation = next(
(
variation
for variation in item_variations
if not variation.get("is_deleted")
),
None,
)
if not item_variation:
raise ValueError("Square subscription item is missing a variation.")
price_money = self._parse_price_money(
(item_variation.get("item_variation_data") or {}).get("price_money")
)
if price_money:
return price_money
raise ValueError("Square subscription item variation is missing price_money.")
def _parse_price_money(
self, price_money: dict[str, Any] | None
) -> dict[str, Any] | None:
if (
price_money
and price_money.get("amount") is not None
and price_money.get("currency")
):
return {
"amount": int(price_money["amount"]),
"currency": price_money["currency"].upper(),
}
return None
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
status = (payment.get("status") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _create_checkout_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
opts: SquareCreateInvoiceOptions,
memo: str | None = None,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
co = opts.checkout or SquareCheckoutOptions()
success_url = (
co.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
metadata = {
**co.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
}
payload = {
"idempotency_key": payment_hash,
"order": {
"location_id": self.location_id,
"metadata": metadata,
"line_items": [
{
"name": line_item_name,
"quantity": "1",
"base_price_money": {
"amount": amount_cents,
"currency": currency.upper(),
},
}
],
},
"checkout_options": {"redirect_url": success_url},
}
if memo:
payload["payment_note"] = memo[:500]
try:
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
order_id = payment_link.get("order_id")
url = payment_link.get("url")
if not order_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _create_subscription_invoice(
self, opts: SquareSubscriptionOptions | None
) -> FiatInvoiceResponse:
term = opts or SquareSubscriptionOptions()
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
return FiatInvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=term.payment_request or "",
)
def _normalize_square_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_square_", "", 1)
if checking_id.startswith("fiat_square_")
else checking_id
)
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> SquareCreateInvoiceOptions | None:
try:
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Square options: {e}")
return None
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
extra_link = None
if payment_options.extra:
raw_link = payment_options.extra.get("link")
extra_link = str(raw_link)[:200] if raw_link else None
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
extra_link,
]
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
else:
meta.append(None)
metadata = json.dumps(meta, separators=(",", ":"))
if len(metadata) > 500:
raise ValueError("Square subscription metadata is too long.")
return metadata
async def _get_square_subscription_id(
self, subscription_id: str, wallet_id: str
) -> str:
try:
from lnbits.core.crud.payments import get_payments
from lnbits.core.models import PaymentFilters
from lnbits.db import Filter, Filters
payments = await get_payments(
wallet_id=wallet_id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id and payment.fiat_provider == "square"
),
None,
)
if payment and payment.external_id:
return payment.external_id
payments = await get_payments(
wallet_id=wallet_id,
incoming=True,
filters=Filters(
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id
and payment.fiat_provider == "square"
and (payment.extra or {}).get("subscription_request_id")
== subscription_id
),
None,
)
if payment and payment.external_id:
return payment.external_id
except Exception as exc:
logger.warning(exc)
return subscription_id
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.square_api_endpoint),
str(settings.square_access_token),
str(settings.square_location_id),
str(settings.square_api_version),
]
)
-78
View File
@@ -1,78 +0,0 @@
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
from typing import Any
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
def generate_llms_txt(app: FastAPI) -> str:
"""Convert an OpenAPI schema to llms.txt markdown format."""
openapi_schema = app.openapi()
lines: list[str] = []
# H1: API Title
info = openapi_schema.get("info", {})
title = info.get("title", "API")
lines.append(f"# {title}")
lines.append("")
# Blockquote: Description
description = info.get("description")
if description:
for line in description.strip().split("\n"):
lines.append(f"> {line}")
lines.append("")
# Group endpoints by tag
paths = openapi_schema.get("paths", {})
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
for path, path_item in paths.items():
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
if method not in path_item:
continue
operation = path_item[method]
tags = operation.get("tags", ["Endpoints"])
tag = tags[0] if tags else "Endpoints"
if tag not in endpoints_by_tag:
endpoints_by_tag[tag] = []
endpoints_by_tag[tag].append(
{
"path": path,
"method": method.upper(),
"operation": operation,
}
)
# Generate sections by tag
for tag, endpoints in endpoints_by_tag.items():
lines.append(f"## {tag}")
lines.append("")
for endpoint in endpoints:
method = endpoint["method"]
path = endpoint["path"]
operation = endpoint["operation"]
summary = operation.get("summary", "")
if summary:
lines.append(f"### `{method} {path}` - {summary}")
else:
lines.append(f"### `{method} {path}`")
lines.append("")
lines.append("")
return "\n".join(lines).strip() + "\n"
def create_llms_txt_route(app: FastAPI) -> None:
"""Add a /llms.txt endpoint to the app."""
@app.get(
"/llms.txt",
response_class=PlainTextResponse,
include_in_schema=False,
summary="Get LLM-friendly API documentation",
)
async def get_llms_txt() -> str:
"""Return the API documentation in llms.txt markdown format."""
return generate_llms_txt(app)
-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)
+12 -80
View File
@@ -284,7 +284,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=[])
@@ -300,7 +300,6 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings):
@@ -324,6 +323,11 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json",
"text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -359,11 +363,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -589,7 +591,6 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings):
@@ -614,7 +615,6 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -652,7 +652,6 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings):
@@ -704,35 +703,6 @@ class PayPalFiatProvider(LNbitsSettings):
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class SquareFiatProvider(LNbitsSettings):
square_enabled: bool = Field(default=False)
square_api_endpoint: str = Field(default="https://connect.squareup.com")
square_access_token: str | None = Field(default=None)
square_location_id: str | None = Field(default=None)
square_api_version: str = Field(default="2026-01-22")
square_payment_success_url: str = Field(default="https://lnbits.com")
square_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
)
square_webhook_signature_key: str | None = Field(default=None)
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class RevolutFiatProvider(LNbitsSettings):
revolut_enabled: bool = Field(default=False)
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
revolut_api_secret_key: str | None = Field(default=None)
revolut_api_version: str = Field(default="2026-04-20")
revolut_payment_success_url: str = Field(default="https://lnbits.com")
revolut_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
)
revolut_webhook_signing_secret: str | None = Field(default=None)
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -770,12 +740,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@@ -786,10 +751,6 @@ class FiatProvidersSettings(
return self.stripe_enabled
if provider == "paypal":
return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -809,18 +770,6 @@ class FiatProvidersSettings(
):
allowed_providers.append("paypal")
if self.square_enabled and (
not self.square_limits.allowed_users
or user_id in self.square_limits.allowed_users
):
allowed_providers.append("square")
if self.revolut_enabled and (
not self.revolut_limits.allowed_users
or user_id in self.revolut_limits.allowed_users
):
allowed_providers.append("revolut")
return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -845,16 +794,6 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False)
class BlockExplorerSettings(LNbitsSettings):
lnbits_blockexplorer_enabled: bool = Field(default=False)
lnbits_blockexplorer_public_api: bool = Field(default=False)
lnbits_blockexplorer_electrum_url: str = Field(
default="ssl://electrum.blockstream.info:50002"
)
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
lnbits_blockexplorer_network: str = Field(default="main")
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105
@@ -1028,7 +967,6 @@ class EditableSettings(
LightningSettings,
WebPushSettings,
NodeUISettings,
BlockExplorerSettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
@@ -1063,7 +1001,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings):
class Config(EditableSettings.Config):
class Config:
extra = Extra.forbid
@@ -1071,7 +1009,6 @@ 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.
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
@@ -1088,12 +1025,11 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
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)
task_heart_beat_verbose: bool = Field(default=False)
task_heart_beat_interval: int = Field(default=30)
@property
def has_default_extension_path(self) -> bool:
@@ -1215,11 +1151,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback # type: ignore[assignment]
json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str) -> bool:
return (
@@ -1279,7 +1215,6 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
@@ -1300,7 +1235,6 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1347,7 +1281,6 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
@@ -1365,7 +1298,6 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -18
View File
File diff suppressed because one or more lines are too long
+4 -14
View File
@@ -212,15 +212,12 @@ body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
}
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
body.body--dark .q-header,
body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
backdrop-filter: brightness(0.8);
backdrop-filter: blur(6px) brightness(0.8);
}
body.rounded-ui .q-card,
@@ -391,18 +388,11 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
}
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
}
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
body.no-burger-background .q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
:root {
+2 -44
View File
@@ -191,6 +191,7 @@ window.localisation.br = {
only_admins_can_create_extensions:
'Apenas contas de administrador podem criar extensões',
admin_only: 'Apenas para Administração',
super_user_only: 'Apenas para superusuário',
make_user_admin: 'Tornar usuário administrador',
revoke_admin: 'Revogar Admin',
new_version: 'Nova Versão',
@@ -824,48 +825,5 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor',
sort: 'Ordenar',
sort_by: 'Ordenar por',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem transações demais)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
sort_by: 'Ordenar por'
}
+2 -40
View File
@@ -135,6 +135,7 @@ window.localisation.cn = {
all: '全部',
only_admins_can_install: '(只有管理员账户可以安装扩展)',
admin_only: '仅限管理员',
super_user_only: '仅限超级用户',
new_version: '新版本',
extension_depends_on: '依赖于:',
extension_rating_soon: '即将推出评分',
@@ -419,44 +420,5 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码',
request_details: '请求详情',
http_request_details: 'HTTP请求详细信息',
block_explorer: '区块浏览器',
enable_block_explorer: '启用区块浏览器',
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
blockexplorer_public_api: '公开 API 访问',
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
electrum_server_url: 'Electrum 服务器 URL',
electrum_server_url_hint:
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
blockexplorer_search_label: '按 TXID 或地址搜索',
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
recent_blocks: '最新区块',
chain_tip: '链尖',
block_height: '区块高度',
block_fee: '区块手续费',
fee_estimates: '手续费估算',
confirmed_balance: '已确认余额',
unconfirmed_balance: '未确认余额',
transaction_history: '交易历史',
coinbase: 'Coinbase',
inputs: '输入',
outputs: '输出',
confirmations: '确认数',
confirmed: '已确认',
unconfirmed: '未确认',
history_unavailable: '交易历史不可用(地址交易过多)',
address: '地址',
block_number: '区块 #{height}',
block_diff: '难度 {value}',
block_hash: '哈希',
previous_block: '上一区块',
merkle_root: 'Merkle 根',
version: '版本',
bits: 'Bits',
difficulty: '难度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '虚拟大小',
weight: '权重',
n_block_fee: '{n} 区块手续费'
http_request_details: 'HTTP请求详细信息'
}
+2 -44
View File
@@ -140,6 +140,7 @@ window.localisation.cs = {
only_admins_can_install:
'(Pouze administrátorské účty mohou instalovat rozšíření)',
admin_only: 'Pouze pro adminy',
super_user_only: 'Pouze pro superuživatele',
new_version: 'Nová verze',
extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnocení brzy dostupné',
@@ -439,48 +440,5 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti',
http_request_details: 'Podrobnosti HTTP žádosti',
block_explorer: 'Průzkumník bloků',
enable_block_explorer: 'Povolit průzkumník bloků',
block_explorer_desc:
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
blockexplorer_public_api: 'Veřejný přístup k API',
blockexplorer_public_api_desc:
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
electrum_server_url: 'URL Electrum serveru',
electrum_server_url_hint:
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
recent_blocks: 'Nedávné bloky',
chain_tip: 'Vrchol řetězu',
block_height: 'Výška bloku',
block_fee: 'poplatek bloku',
fee_estimates: 'Odhady poplatků',
confirmed_balance: 'Potvrzený zůstatek',
unconfirmed_balance: 'Nepotvrzený zůstatek',
transaction_history: 'Historie transakcí',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrzení',
confirmed: 'Potvrzeno',
unconfirmed: 'Nepotvrzeno',
history_unavailable:
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Předchozí blok',
merkle_root: 'Merkle kořen',
version: 'Verze',
bits: 'Bity',
difficulty: 'Obtížnost',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuální velikost',
weight: 'Váha',
n_block_fee: 'poplatek {n} bloků'
http_request_details: 'Podrobnosti HTTP žádosti'
}
+2 -44
View File
@@ -143,6 +143,7 @@ window.localisation.de = {
only_admins_can_install:
'(Nur Administratorkonten können Erweiterungen installieren)',
admin_only: 'Nur für Admins',
super_user_only: 'Nur für Superuser',
new_version: 'Neue Version',
extension_depends_on: 'Hängt ab von:',
extension_rating_soon: 'Bewertungen sind bald verfügbar',
@@ -452,48 +453,5 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails',
http_request_details: 'HTTP-Anfragedetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer aktivieren',
block_explorer_desc:
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
blockexplorer_public_api: 'Öffentlicher API-Zugang',
blockexplorer_public_api_desc:
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
electrum_server_url: 'Electrum-Server-URL',
electrum_server_url_hint:
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
blockexplorer_search_hint:
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
recent_blocks: 'Aktuelle Blöcke',
chain_tip: 'Kettenspitze',
block_height: 'Blockhöhe',
block_fee: 'Blockgebühr',
fee_estimates: 'Gebührenschätzungen',
confirmed_balance: 'Bestätigtes Guthaben',
unconfirmed_balance: 'Unbestätigtes Guthaben',
transaction_history: 'Transaktionsverlauf',
coinbase: 'Coinbase',
inputs: 'Eingaben',
outputs: 'Ausgaben',
confirmations: 'Bestätigungen',
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
history_unavailable:
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
address: 'Adresse',
block_number: 'Block #{height}',
block_diff: 'Schw. {value}',
block_hash: 'Hash',
previous_block: 'Vorheriger Block',
merkle_root: 'Merkle-Wurzel',
version: 'Version',
bits: 'Bits',
difficulty: 'Schwierigkeit',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuelle Größe',
weight: 'Gewicht',
n_block_fee: '{n}-Block-Gebühr'
http_request_details: 'HTTP-Anfragedetails'
}
+2 -65
View File
@@ -184,12 +184,12 @@ window.localisation.en = {
release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured',
categories: 'Categories',
all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
super_user_only: 'Super User Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version',
@@ -268,15 +268,6 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
webhook_square_description:
'On the Square side configure a webhook pointing to this exact LNbits URL.',
square_webhook_url_hint:
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
access_token: 'Access Token',
location_id: 'Location ID',
square_location_id_hint:
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
api_version: 'API Version',
payment_proof: 'Payment Proof',
update: 'Update',
update_available: 'Update {version} available!',
@@ -491,8 +482,6 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language',
assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)',
@@ -817,8 +806,6 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
square_webhook_signature_key_hint:
'Square webhook signature key used to verify incoming events.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
@@ -841,55 +828,5 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated',
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Enable Block Explorer',
block_explorer_desc:
'Allow users to explore Bitcoin transactions and addresses via Electrum.',
blockexplorer_public_api: 'Public API Access',
blockexplorer_public_api_desc:
'Allow unauthenticated access to the block explorer API endpoints.',
electrum_compatible_server: 'Electrum compatible server',
electrum_server_url: 'Electrum Server URL',
electrum_server_url_hint:
'Choose a public Electrum server or enter your own.',
electrum_server_url_custom: 'Custom Electrum Server URL',
view_public_electrum_servers: 'View public Electrum servers',
blockexplorer_network: 'Bitcoin Network',
blockexplorer_network_hint:
'The network the Electrum server is connected to, used to render addresses correctly.',
blockexplorer_search_label: 'Search by TXID or Address',
blockexplorer_search_hint:
'64-char hex = transaction · anything else = Bitcoin address',
recent_blocks: 'Recent Blocks',
chain_tip: 'Chain Tip',
block_height: 'Block Height',
block_fee: 'block fee',
fee_estimates: 'Fee Estimates',
confirmed_balance: 'Confirmed Balance',
unconfirmed_balance: 'Unconfirmed Balance',
transaction_history: 'Transaction History',
coinbase: 'Coinbase',
inputs: 'Inputs',
outputs: 'Outputs',
confirmations: 'Confirmations',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found',
history_unavailable:
'Transaction history unavailable (address has too many transactions)',
address: 'Address',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block fee'
sort_by: 'Sort by'
}
+2 -44
View File
@@ -142,6 +142,7 @@ window.localisation.es = {
only_admins_can_install:
'(Solo las cuentas de administrador pueden instalar extensiones)',
admin_only: 'Solo administradores',
super_user_only: 'Solo superusuario',
new_version: 'Nueva Versión',
extension_depends_on: 'Depende de:',
extension_rating_soon: 'Calificaciones próximamente',
@@ -453,48 +454,5 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud',
http_request_details: 'Detalles de la Solicitud HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activar Block Explorer',
block_explorer_desc:
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
blockexplorer_public_api: 'Acceso a la API pública',
blockexplorer_public_api_desc:
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
electrum_server_url: 'URL del servidor Electrum',
electrum_server_url_hint:
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Buscar por TXID o dirección',
blockexplorer_search_hint:
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
recent_blocks: 'Bloques recientes',
chain_tip: 'Punta de cadena',
block_height: 'Altura de bloque',
block_fee: 'tarifa de bloque',
fee_estimates: 'Estimaciones de tarifa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo no confirmado',
transaction_history: 'Historial de transacciones',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Salidas',
confirmations: 'Confirmaciones',
confirmed: 'Confirmado',
unconfirmed: 'No confirmado',
history_unavailable:
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
address: 'Dirección',
block_number: 'Bloque #{height}',
block_diff: 'dif {value}',
block_hash: 'Hash',
previous_block: 'Bloque anterior',
merkle_root: 'Raíz de Merkle',
version: 'Versión',
bits: 'Bits',
difficulty: 'Dificultad',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamaño virtual',
weight: 'Peso',
n_block_fee: 'tarifa {n} bloques'
http_request_details: 'Detalles de la Solicitud HTTP'
}
+2 -44
View File
@@ -154,6 +154,7 @@ window.localisation.fi = {
all: 'Kaikki',
only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)',
admin_only: 'Pääkäyttäjille',
super_user_only: 'Vain superkäyttäjälle',
new_version: 'Uusi versio',
extension_depends_on: 'Edellyttää:',
extension_rating_soon: 'Arvostelut on tulossa pian',
@@ -647,48 +648,5 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
block_explorer: 'Lohkoselain',
enable_block_explorer: 'Ota lohkoselain käyttöön',
block_explorer_desc:
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
blockexplorer_public_api: 'Julkinen API-pääsy',
blockexplorer_public_api_desc:
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
electrum_server_url: 'Electrum-palvelimen URL',
electrum_server_url_hint:
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
blockexplorer_search_hint:
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
recent_blocks: 'Viimeisimmät lohkot',
chain_tip: 'Ketjun kärki',
block_height: 'Lohkokorkeus',
block_fee: 'lohkomaksu',
fee_estimates: 'Maksuarviot',
confirmed_balance: 'Vahvistettu saldo',
unconfirmed_balance: 'Vahvistamaton saldo',
transaction_history: 'Tapahtumahistoria',
coinbase: 'Coinbase',
inputs: 'Syötteet',
outputs: 'Tulosteet',
confirmations: 'Vahvistukset',
confirmed: 'Vahvistettu',
unconfirmed: 'Vahvistamaton',
history_unavailable:
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
address: 'Osoite',
block_number: 'Lohko #{height}',
block_diff: 'vaikeus {value}',
block_hash: 'Hash',
previous_block: 'Edellinen lohko',
merkle_root: 'Merkle-juuri',
version: 'Versio',
bits: 'Bitit',
difficulty: 'Vaikeus',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuaalikoko',
weight: 'Paino',
n_block_fee: '{n} lohkon maksu'
'The user will be redirected to this URL after the payment is successful'
}
+2 -44
View File
@@ -145,6 +145,7 @@ window.localisation.fr = {
only_admins_can_install:
'Seuls les comptes administrateurs peuvent installer des extensions',
admin_only: 'Réservé aux administrateurs',
super_user_only: 'Réservé au super utilisateur',
new_version: 'Nouvelle version',
extension_depends_on: 'Dépend de :',
extension_rating_soon: 'Notes des utilisateurs à venir bientôt',
@@ -456,48 +457,5 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande',
http_request_details: 'Détails de la requête HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activer le Block Explorer',
block_explorer_desc:
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
blockexplorer_public_api: 'Accès API public',
blockexplorer_public_api_desc:
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
electrum_server_url: 'URL du serveur Electrum',
electrum_server_url_hint:
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
blockexplorer_search_hint:
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
recent_blocks: 'Blocs récents',
chain_tip: 'Sommet de chaîne',
block_height: 'Hauteur de bloc',
block_fee: 'frais de bloc',
fee_estimates: 'Estimations de frais',
confirmed_balance: 'Solde confirmé',
unconfirmed_balance: 'Solde non confirmé',
transaction_history: 'Historique des transactions',
coinbase: 'Coinbase',
inputs: 'Entrées',
outputs: 'Sorties',
confirmations: 'Confirmations',
confirmed: 'Confirmé',
unconfirmed: 'Non confirmé',
history_unavailable:
'Historique des transactions indisponible (adresse avec trop de transactions)',
address: 'Adresse',
block_number: 'Bloc #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloc précédent',
merkle_root: 'Racine de Merkle',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulté',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Taille virtuelle',
weight: 'Poids',
n_block_fee: 'frais {n} blocs'
http_request_details: 'Détails de la requête HTTP'
}
+2 -44
View File
@@ -142,6 +142,7 @@ window.localisation.it = {
only_admins_can_install:
'Solo gli account amministratore possono installare estensioni.',
admin_only: 'Solo amministratore',
super_user_only: 'Solo superutente',
new_version: 'Nuova Versione',
extension_depends_on: 'Dipende da:',
extension_rating_soon: 'Valutazioni in arrivo',
@@ -450,48 +451,5 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta',
http_request_details: 'Dettagli della richiesta HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Abilita Block Explorer',
block_explorer_desc:
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
blockexplorer_public_api: 'Accesso API pubblico',
blockexplorer_public_api_desc:
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
electrum_server_url: 'URL server Electrum',
electrum_server_url_hint:
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
blockexplorer_search_hint:
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
recent_blocks: 'Blocchi recenti',
chain_tip: 'Punta della catena',
block_height: 'Altezza blocco',
block_fee: 'commissione blocco',
fee_estimates: 'Stime delle commissioni',
confirmed_balance: 'Saldo confermato',
unconfirmed_balance: 'Saldo non confermato',
transaction_history: 'Storico transazioni',
coinbase: 'Coinbase',
inputs: 'Input',
outputs: 'Output',
confirmations: 'Conferme',
confirmed: 'Confermato',
unconfirmed: 'Non confermato',
history_unavailable:
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
address: 'Indirizzo',
block_number: 'Blocco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Blocco precedente',
merkle_root: 'Radice di Merkle',
version: 'Versione',
bits: 'Bit',
difficulty: 'Difficoltà',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Dimensione virtuale',
weight: 'Peso',
n_block_fee: 'commissione {n} blocchi'
http_request_details: 'Dettagli della richiesta HTTP'
}
+2 -44
View File
@@ -137,6 +137,7 @@ window.localisation.jp = {
only_admins_can_install:
'(管理者アカウントのみが拡張機能をインストールできます)',
admin_only: '管理者のみ',
super_user_only: 'スーパー ユーザーのみ',
new_version: '新しいバージョン',
extension_depends_on: '依存先:',
extension_rating_soon: '評価は近日公開',
@@ -440,48 +441,5 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細',
http_request_details: 'HTTPリクエストの詳細',
block_explorer: 'ブロックエクスプローラー',
enable_block_explorer: 'ブロックエクスプローラーを有効化',
block_explorer_desc:
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
blockexplorer_public_api: 'パブリックAPIアクセス',
blockexplorer_public_api_desc:
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
electrum_server_url: 'ElectrumサーバーURL',
electrum_server_url_hint:
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
blockexplorer_search_hint:
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
recent_blocks: '最新ブロック',
chain_tip: 'チェーン先端',
block_height: 'ブロック高さ',
block_fee: 'ブロック手数料',
fee_estimates: '手数料見積もり',
confirmed_balance: '確認済み残高',
unconfirmed_balance: '未確認残高',
transaction_history: 'トランザクション履歴',
coinbase: 'コインベース',
inputs: 'インプット',
outputs: 'アウトプット',
confirmations: '確認数',
confirmed: '確認済み',
unconfirmed: '未確認',
history_unavailable:
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
address: 'アドレス',
block_number: 'ブロック #{height}',
block_diff: 'diff {value}',
block_hash: 'ハッシュ',
previous_block: '前のブロック',
merkle_root: 'マークルルート',
version: 'バージョン',
bits: 'Bits',
difficulty: '難易度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '仮想サイズ',
weight: '重量',
n_block_fee: '{n}ブロック手数料'
http_request_details: 'HTTPリクエストの詳細'
}
+2 -43
View File
@@ -139,6 +139,7 @@ window.localisation.kr = {
all: '전체',
only_admins_can_install: '(관리자 계정만이 확장 기능을 설치할 수 있습니다)',
admin_only: '관리자 전용',
super_user_only: '슈퍼유저 전용',
new_version: '새로운 버전',
extension_depends_on: '의존성 존재:',
extension_rating_soon: '평점 기능도 곧 구현됩니다',
@@ -435,47 +436,5 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항',
http_request_details: 'HTTP 요청 세부사항',
block_explorer: '블록 탐색기',
enable_block_explorer: '블록 탐색기 활성화',
block_explorer_desc:
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
blockexplorer_public_api: '공개 API 접근',
blockexplorer_public_api_desc:
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
electrum_server_url: 'Electrum 서버 URL',
electrum_server_url_hint:
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
blockexplorer_search_label: 'TXID 또는 주소로 검색',
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
recent_blocks: '최근 블록',
chain_tip: '체인 끝',
block_height: '블록 높이',
block_fee: '블록 수수료',
fee_estimates: '수수료 추정',
confirmed_balance: '확인된 잔액',
unconfirmed_balance: '미확인 잔액',
transaction_history: '거래 내역',
coinbase: 'Coinbase',
inputs: '입력',
outputs: '출력',
confirmations: '확인 수',
confirmed: '확인됨',
unconfirmed: '미확인',
history_unavailable:
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
address: '주소',
block_number: '블록 #{height}',
block_diff: 'diff {value}',
block_hash: '해시',
previous_block: '이전 블록',
merkle_root: '머클 루트',
version: '버전',
bits: 'Bits',
difficulty: '난이도',
nonce: 'Nonce',
txid: 'TXID',
vsize: '가상 크기',
weight: '무게',
n_block_fee: '{n}블록 수수료'
http_request_details: 'HTTP 요청 세부사항'
}
+2 -44
View File
@@ -143,6 +143,7 @@ window.localisation.nl = {
only_admins_can_install:
'Alleen beheerdersaccounts kunnen extensies installeren',
admin_only: 'Alleen beheerder',
super_user_only: 'Alleen supergebruiker',
new_version: 'Nieuwe Versie',
extension_depends_on: 'Afhankelijk van:',
extension_rating_soon: 'Beoordelingen binnenkort beschikbaar',
@@ -450,48 +451,5 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails',
http_request_details: 'HTTP-verzoekdetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer inschakelen',
block_explorer_desc:
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
blockexplorer_public_api: 'Publieke API-toegang',
blockexplorer_public_api_desc:
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
electrum_server_url: 'Electrum-server-URL',
electrum_server_url_hint:
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
blockexplorer_search_label: 'Zoeken op TXID of adres',
blockexplorer_search_hint:
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
recent_blocks: 'Recente blokken',
chain_tip: 'Kettingtop',
block_height: 'Blokhoogte',
block_fee: 'blokvergoeding',
fee_estimates: 'Vergoedingsschattingen',
confirmed_balance: 'Bevestigd saldo',
unconfirmed_balance: 'Onbevestigd saldo',
transaction_history: 'Transactiegeschiedenis',
coinbase: 'Coinbase',
inputs: 'Invoer',
outputs: 'Uitvoer',
confirmations: 'Bevestigingen',
confirmed: 'Bevestigd',
unconfirmed: 'Onbevestigd',
history_unavailable:
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'moeil. {value}',
block_hash: 'Hash',
previous_block: 'Vorig blok',
merkle_root: 'Merkle-wortel',
version: 'Versie',
bits: 'Bits',
difficulty: 'Moeilijkheid',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuele grootte',
weight: 'Gewicht',
n_block_fee: '{n}-blok vergoeding'
http_request_details: 'HTTP-verzoekdetails'
}
+2 -44
View File
@@ -141,6 +141,7 @@ window.localisation.pi = {
all: 'Arr',
only_admins_can_install: '(Only admin accounts can install extensions)',
admin_only: "Cap'n Only",
super_user_only: "Big Cap'n Only",
new_version: 'New Version',
extension_depends_on: 'Depends on:',
extension_rating_soon: "Ratings a'comin' soon",
@@ -441,48 +442,5 @@ window.localisation.pi = {
http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details',
http_request_details: 'HTTP Request Details',
block_explorer: 'Treasure Map',
enable_block_explorer: 'Hoist the Treasure Map',
block_explorer_desc:
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
blockexplorer_public_api: 'Open Seas API',
blockexplorer_public_api_desc:
'Allow any landlubber access to the block explorer API ports.',
electrum_server_url: 'Electrum Port URL',
electrum_server_url_hint:
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
blockexplorer_search_label: 'Search by TXID or Port',
blockexplorer_search_hint:
'64-char hex = plunder · anything else = Bitcoin port',
recent_blocks: 'Recent Plunder',
chain_tip: "Tip o' the Anchor Chain",
block_height: 'Plunder Height',
block_fee: 'plunder fee',
fee_estimates: 'Booty Estimates',
confirmed_balance: 'Confirmed Booty',
unconfirmed_balance: 'Unconfirmed Booty',
transaction_history: 'Plunder History',
coinbase: 'Coinbase',
inputs: 'Inbound Plunder',
outputs: 'Outbound Plunder',
confirmations: 'Confirmations, arr',
confirmed: 'Confirmed, arr',
unconfirmed: 'Unconfirmed, arr',
history_unavailable:
'Plunder history lost at sea (too many transactions, matey!)',
address: 'Port',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Plunder Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block booty'
http_request_details: 'HTTP Request Details'
}
+2 -44
View File
@@ -140,6 +140,7 @@ window.localisation.pl = {
only_admins_can_install:
'Tylko konta administratorów mogą instalować rozszerzenia',
admin_only: 'Tylko dla administratora',
super_user_only: 'Tylko dla superużytkownika',
new_version: 'Nowa wersja',
extension_depends_on: 'Zależy od:',
extension_rating_soon: 'Oceny będą dostępne wkrótce',
@@ -444,48 +445,5 @@ window.localisation.pl = {
http_request_methods: 'Metody żądań HTTP',
http_response_codes: 'Kody Odpowiedzi HTTP',
request_details: 'Szczegóły żądania',
http_request_details: 'Szczegóły żądania HTTP',
block_explorer: 'Przeglądarka bloków',
enable_block_explorer: 'Włącz przeglądarkę bloków',
block_explorer_desc:
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
blockexplorer_public_api: 'Publiczny dostęp do API',
blockexplorer_public_api_desc:
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
electrum_server_url: 'URL serwera Electrum',
electrum_server_url_hint:
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
blockexplorer_search_hint:
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
recent_blocks: 'Ostatnie bloki',
chain_tip: 'Wierzchołek łańcucha',
block_height: 'Wysokość bloku',
block_fee: 'opłata bloku',
fee_estimates: 'Szacunki opłat',
confirmed_balance: 'Potwierdzony saldo',
unconfirmed_balance: 'Niepotwierdzony saldo',
transaction_history: 'Historia transakcji',
coinbase: 'Coinbase',
inputs: 'Wejścia',
outputs: 'Wyjścia',
confirmations: 'Potwierdzenia',
confirmed: 'Potwierdzone',
unconfirmed: 'Niepotwierdzone',
history_unavailable:
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'trud. {value}',
block_hash: 'Hash',
previous_block: 'Poprzedni blok',
merkle_root: 'Korzeń Merkle',
version: 'Wersja',
bits: 'Bity',
difficulty: 'Trudność',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Rozmiar wirtualny',
weight: 'Waga',
n_block_fee: 'opłata {n} bloków'
http_request_details: 'Szczegóły żądania HTTP'
}
+2 -44
View File
@@ -141,6 +141,7 @@ window.localisation.pt = {
only_admins_can_install:
'Apenas contas de administrador podem instalar extensões.',
admin_only: 'Apenas para administradores',
super_user_only: 'Apenas para superusuário',
new_version: 'Nova Versão',
extension_depends_on: 'Depende de:',
extension_rating_soon: 'Avaliações em breve',
@@ -446,48 +447,5 @@ window.localisation.pt = {
http_request_methods: 'Métodos de Requisição HTTP',
http_response_codes: 'Códigos de Resposta HTTP',
request_details: 'Detalhes da solicitação',
http_request_details: 'Detalhes da Solicitação HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos utilizadores explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem demasiadas transações)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
http_request_details: 'Detalhes da Solicitação HTTP'
}
+2 -44
View File
@@ -138,6 +138,7 @@ window.localisation.sk = {
only_admins_can_install:
'(Iba administrátorské účty môžu inštalovať rozšírenia)',
admin_only: 'Iba pre administrátorov',
super_user_only: 'Iba pre superužívateľa',
new_version: 'Nová verzia',
extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnotenia budú čoskoro dostupné',
@@ -444,48 +445,5 @@ window.localisation.sk = {
http_request_methods: 'Metódy HTTP žiadostí',
http_response_codes: 'Kódy odpovedí HTTP',
request_details: 'Podrobnosti žiadosti',
http_request_details: 'Podrobnosti požiadavky HTTP',
block_explorer: 'Prehliadač blokov',
enable_block_explorer: 'Povoliť prehliadač blokov',
block_explorer_desc:
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
blockexplorer_public_api: 'Verejný prístup k API',
blockexplorer_public_api_desc:
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
electrum_server_url: 'URL Electrum servera',
electrum_server_url_hint:
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
recent_blocks: 'Nedávne bloky',
chain_tip: 'Vrchol reťaze',
block_height: 'Výška bloku',
block_fee: 'poplatok bloku',
fee_estimates: 'Odhady poplatkov',
confirmed_balance: 'Potvrdený zostatok',
unconfirmed_balance: 'Nepotvrdený zostatok',
transaction_history: 'História transakcií',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrdenia',
confirmed: 'Potvrdené',
unconfirmed: 'Nepotvrdené',
history_unavailable:
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Predchádzajúci blok',
merkle_root: 'Merkle koreň',
version: 'Verzia',
bits: 'Bity',
difficulty: 'Obťažnosť',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuálna veľkosť',
weight: 'Váha',
n_block_fee: 'poplatok {n} blokov'
http_request_details: 'Podrobnosti požiadavky HTTP'
}
+2 -44
View File
@@ -139,6 +139,7 @@ window.localisation.we = {
all: 'Pob',
only_admins_can_install: 'Dim ond cyfrifon gweinyddwr all osod estyniadau',
admin_only: 'Dim ond Gweinyddwr',
super_user_only: 'Dim ond Uwchddefnyddiwr',
new_version: 'Fersiwn Newydd',
extension_depends_on: 'Dibynnu ar:',
extension_rating_soon: 'Sgôr yn dod yn fuan',
@@ -442,48 +443,5 @@ window.localisation.we = {
http_request_methods: 'Dulliau Cais HTTP',
http_response_codes: 'Codau Ymateb HTTP',
request_details: 'Manylion y Cais',
http_request_details: 'Manylion Cais HTTP',
block_explorer: 'Archwiliwr Bloc',
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
block_explorer_desc:
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
blockexplorer_public_api_desc:
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
electrum_server_url: 'URL Gweinydd Electrum',
electrum_server_url_hint:
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
blockexplorer_search_hint:
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
recent_blocks: 'Blociau Diweddar',
chain_tip: 'Blaen y Gadwyn',
block_height: 'Uchder Bloc',
block_fee: 'ffi bloc',
fee_estimates: 'Amcangyfrifon Ffi',
confirmed_balance: 'Balans Cadarnhawyd',
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
transaction_history: 'Hanes Trafodion',
coinbase: 'Coinbase',
inputs: 'Mewnbynnau',
outputs: 'Allbynnau',
confirmations: 'Cadarnhadau',
confirmed: 'Cadarnhawyd',
unconfirmed: 'Heb ei Gadarnhau',
history_unavailable:
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
address: 'Cyfeiriad',
block_number: 'Bloc #{height}',
block_diff: 'anhawster {value}',
block_hash: 'Hash',
previous_block: 'Bloc Blaenorol',
merkle_root: 'Gwreiddyn Merkle',
version: 'Fersiwn',
bits: 'Bits',
difficulty: 'Anhawster',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Maint Rhithwir',
weight: 'Pwysau',
n_block_fee: 'ffi {n} bloc'
http_request_details: 'Manylion Cais HTTP'
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

-9
View File
@@ -193,14 +193,5 @@ window._lnbitsApi = {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
},
getBlockexplorerAddress(address) {
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
},
getBlockexplorerTransaction(txid) {
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
},
getBlockexplorerUtxos(address) {
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
}
}
+1 -3
View File
@@ -452,9 +452,7 @@ window.app.component('username-password', {
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false,
showPwd: false,
showPwdRepeat: false
showConfirmationCode: false
}
},
methods: {
@@ -1,40 +0,0 @@
window.app.component('lnbits-admin-blockexplorer', {
props: ['form-data'],
template: '#lnbits-admin-blockexplorer',
data() {
return {
electrumServers: [
'ssl://fulcrum.lnbits.com:50002',
'ssl://mainnet.nunchuk.io:52002',
'ssl://fulcrum.grey.pw:50002',
'ssl://electrum2.bluewallet.io:443',
'ssl://electrum.acinq.co:50002',
'ssl://electrum.blockstream.info:50002',
'ssl://bitcoin.mullvad.net:5010'
]
}
},
computed: {
electrumServerOptions() {
return [...this.electrumServers, 'Custom']
},
electrumServerPreset: {
get() {
return this.electrumServers.includes(
this.formData.lnbits_blockexplorer_electrum_url
)
? this.formData.lnbits_blockexplorer_electrum_url
: 'Custom'
},
set(value) {
if (value === 'Custom') {
if (this.electrumServerPreset !== 'Custom') {
this.formData.lnbits_blockexplorer_electrum_url = ''
}
return
}
this.formData.lnbits_blockexplorer_electrum_url = value
}
}
}
})
@@ -62,6 +62,12 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() {
this.getExchangeRateHistory()
},
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -121,21 +127,18 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true
},
initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
)
const exchanges = this.formData.lnbits_price_aggregator_enabled
? [{name: 'Aggregator'}]
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
@@ -145,11 +148,7 @@ window.app.component('lnbits-admin-exchange-providers', {
options: {
plugins: {
legend: {
display: true
},
title: {
display: true,
text: 'Bitcoin Price History'
display: false
}
}
},
@@ -5,9 +5,6 @@ window.app.component('lnbits-admin-fiat-providers', {
return {
formAddStripeUser: '',
formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true
}
},
@@ -23,12 +20,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
}
},
watch: {
@@ -67,8 +58,6 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
@@ -88,47 +77,6 @@ window.app.component('lnbits-admin-fiat-providers', {
}
this.copyText(url)
},
isClearnetWebhookUrl(url) {
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (e) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false
}
if (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
host.endsWith('.onion')
) {
return false
}
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host === '0.0.0.0' ||
host === '::1'
) {
return false
}
return true
},
notifyRevolutWebhookWarning(message) {
Quasar.Notify.create({
type: 'warning',
message,
icon: null,
closeBtn: true
})
},
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
@@ -163,40 +111,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
},
addSquareAllowedUser() {
const addUser = this.formAddSquareUser || ''
if (
addUser.length &&
!this.formData.square_limits.allowed_users.includes(addUser)
) {
this.formData.square_limits.allowed_users = [
...this.formData.square_limits.allowed_users,
addUser
]
this.formAddSquareUser = ''
}
},
removeSquareAllowedUser(user) {
this.formData.square_limits.allowed_users =
this.formData.square_limits.allowed_users.filter(u => u !== user)
},
addRevolutAllowedUser() {
const addUser = this.formAddRevolutUser || ''
if (
addUser.length &&
!this.formData.revolut_limits.allowed_users.includes(addUser)
) {
this.formData.revolut_limits.allowed_users = [
...this.formData.revolut_limits.allowed_users,
addUser
]
this.formAddRevolutUser = ''
}
},
removeRevolutAllowedUser(user) {
this.formData.revolut_limits.allowed_users =
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -210,48 +124,6 @@ window.app.component('lnbits-admin-fiat-providers', {
})
})
.catch(LNbits.utils.notifyApiError)
},
createRevolutWebhook() {
const webhookUrl = this.calculateWebhookUrl('revolut')
this.formData.revolut_payment_webhook_url = webhookUrl
if (!this.formData.revolut_api_secret_key) {
this.notifyRevolutWebhookWarning(
'Add your Revolut API secret key before creating a webhook.'
)
return
}
if (!this.isClearnetWebhookUrl(webhookUrl)) {
this.notifyRevolutWebhookWarning(
'Revolut webhook URL must be a clearnet URL.'
)
return
}
this.creatingRevolutWebhook = true
LNbits.api
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
url: webhookUrl,
endpoint: this.formData.revolut_api_endpoint,
api_secret_key: this.formData.revolut_api_secret_key,
api_version: this.formData.revolut_api_version
})
.then(response => {
const data = response.data
this.formData.revolut_payment_webhook_url = data.url
this.formData.revolut_webhook_signing_secret = data.signing_secret
Quasar.Notify.create({
type: 'positive',
message: `Revolut webhook ${
data.already_exists ? 'already exists' : 'created'
}${data.id ? `: ${data.id}` : ''}.`,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.creatingRevolutWebhook = false
})
}
}
})
@@ -1,151 +0,0 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
data() {
return {
@@ -360,37 +360,18 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) {
return row.payment_hash + row.amount
},
async exportCSV(detailed = false) {
exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
const pagination = this.paymentsTable.pagination
const maxPages = 100
const limit = 1000
let payments = []
this.paymentsCSV.loading = true
try {
for (let page = 0; page < maxPages; page++) {
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc',
limit,
offset: page * limit
}
const params = new URLSearchParams(query)
const response = await LNbits.api.getPayments(this.wallet, params)
const pagePayments = response.data.data || []
payments = payments.concat(pagePayments.map(this.mapPayment))
if (
pagePayments.length < limit ||
payments.length >= response.data.total
) {
break
}
}
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc'
}
const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => {
let payments = response.data.data.map(this.mapPayment)
let columns = this.paymentsCSV.columns
if (detailed) {
@@ -419,11 +400,7 @@ window.app.component('lnbits-payment-list', {
payments,
this.wallet.name + '-payments'
)
} catch (err) {
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
})
},
addFilterTag() {
if (!this.exportTagName) return
@@ -8,10 +8,6 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: {
type: String,
default: 'lnurlp'
},
href: {
type: String,
default: ''
}
},
data() {
@@ -25,10 +21,7 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl =
this.href && this.href.trim() !== ''
? `${this.href}?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://')
@@ -85,9 +85,6 @@ window.app.component('lnbits-qrcode', {
event.preventDefault()
event.stopPropagation()
return false
} else if (this.href && this.href.startsWith('http')) {
window.open(this.href, '_blank')
event.preventDefault()
}
},
async writeNfcTag() {
@@ -69,14 +69,6 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow')
}
},
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) {
@@ -158,9 +150,6 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow')
}
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image')
document.body.style.setProperty(
+5 -6
View File
@@ -1,17 +1,16 @@
function eventReaction(amount) {
localUrl = ''
const reaction =
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction
if (!reaction || reaction.toLowerCase() === 'none') {
reaction = localStorage.getItem('lnbits.reactions')
if (!reaction || reaction === 'None') {
return
}
try {
if (amount < 0) {
return
}
if (typeof window[reaction] === 'function') {
window[reaction]()
reaction = localStorage.getItem('lnbits.reactions')
if (reaction) {
window[reaction.split('|')[1]]()
}
} catch (e) {
console.log(e)
-4
View File
@@ -29,10 +29,6 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient
),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore(
'lnbits.backgroundImage',
-37
View File
@@ -27,14 +27,6 @@ const DynamicComponent = {
name: r.name,
component: async () => {
await LNbits.utils.loadTemplate(r.template)
if (r.i18n) {
const locale =
window.i18n?.global?.locale?.value ??
window.i18n?.global?.locale ??
window.g.locale ??
'en'
await LNbits.utils.loadExtI18n(r.i18n, locale)
}
await LNbits.utils.loadScript(r.component)
return window[r.name]
}
@@ -66,11 +58,6 @@ const routes = [
name: 'NodePublic',
component: PageNodePublic
},
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{
path: '/payments',
name: 'Payments',
@@ -164,30 +151,6 @@ window.i18n = new VueI18n.createI18n({
fallbackLocale: 'en',
messages: window.localisation
})
;(function () {
let _applying = false
let _target = null
Vue.watch(
() => window.i18n.global.locale,
async (locale, prevLocale) => {
if (_applying || !LNbits.utils._extI18nDirs.size) return
_target = locale
_applying = true
window.i18n.global.locale = prevLocale
_applying = false
await Promise.all(
[...LNbits.utils._extI18nDirs].map(dir =>
LNbits.utils.loadExtI18n(dir, locale)
)
)
if (_target !== locale) return
_applying = true
window.i18n.global.locale = locale
_applying = false
},
{flush: 'sync'}
)
})()
window.app.mixin({
data() {
+1 -2
View File
@@ -732,8 +732,7 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow,
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
cardShadowChoice: this.g.settings.defaultCardShadow
}
this.siteCustomisationChanged(defaults)
}
-185
View File
@@ -1,185 +0,0 @@
window.PageBlockExplorer = {
template: '#page-blockexplorer',
data() {
return {
query: '',
loading: false,
tip: null,
fees: null,
blocks: [],
selectedBlock: null,
blockDialog: false,
txResult: null,
txStatus: null,
addressResult: null,
currentAddress: ''
}
},
computed: {
feeList() {
if (!this.fees || !this.fees.estimates) return []
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
label: this.$t('n_block_fee', {n: blocks}),
rate: (rate * 100000).toFixed(1) + ' sat/vB'
}))
},
formattedBlocks() {
const now = Math.floor(Date.now() / 1000)
return this.blocks.map(b => ({
...b,
shortHash: b.hash.slice(0, 8) + '...' + b.hash.slice(-4),
timeAgo: this._timeAgo(now - b.timestamp),
utcTime: new Date(b.timestamp * 1000).toUTCString(),
difficulty: this._difficulty(b.bits)
}))
}
},
async created() {
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
this._blockWsActive = true
this._connectBlocksWs()
},
beforeUnmount() {
this._blockWsActive = false
if (this._blockWs) this._blockWs.close()
if (this._searchWs) this._searchWs.close()
},
methods: {
_wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
},
_connectBlocksWs() {
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
ws.onmessage = e => {
const block = JSON.parse(e.data)
const rest = this.blocks.filter(b => b.height !== block.height)
this.blocks = [block, ...rest].slice(0, 5)
}
ws.onerror = () => ws.close()
ws.onclose = () => {
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
}
this._blockWs = ws
},
_connectSearchWs(path, onMessage) {
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
const ws = new WebSocket(this._wsUrl(path))
ws.onmessage = e => {
try {
onMessage(JSON.parse(e.data))
} catch (_) {}
}
ws.onerror = () => ws.close()
this._searchWs = ws
},
_timeAgo(seconds) {
if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
return Math.floor(seconds / 3600) + 'h ago'
},
_difficulty(bitsHex) {
const exp = parseInt(bitsHex.slice(0, 2), 16)
const mantissa = parseInt(bitsHex.slice(2), 16)
const diff1 = 0xffff * Math.pow(2, 208)
const target = mantissa * Math.pow(2, 8 * (exp - 3))
const d = diff1 / target
if (d >= 1e12) return (d / 1e12).toFixed(2) + 'T'
if (d >= 1e9) return (d / 1e9).toFixed(2) + 'G'
if (d >= 1e6) return (d / 1e6).toFixed(2) + 'M'
return d.toFixed(0)
},
openBlock(b) {
this.selectedBlock = b
this.blockDialog = true
},
async loadBlocks() {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/blocks'
)
this.blocks = r.data
} catch (_) {}
},
async loadTip() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tip')
this.tip = r.data
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async loadFees() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/fees')
this.fees = r.data
} catch (_) {}
},
clearResult() {
this.txResult = null
this.txStatus = null
this.addressResult = null
this.query = ''
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
},
async search() {
const q = this.query.trim()
if (!q) return
this.txResult = null
this.txStatus = null
this.addressResult = null
this.loading = true
try {
if (/^[0-9a-fA-F]{64}$/.test(q)) {
await this.loadTx(q)
} else {
await this.loadAddress(q)
}
} finally {
this.loading = false
}
},
async loadTx(txid) {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/tx/' + txid
)
this.txResult = r.data
this.txStatus = null
this.addressResult = null
this.query = txid
this._connectSearchWs(`/ws/tx/${txid}`, data => {
if (!data.error) this.txStatus = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async loadAddress(address) {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/address/' + address
)
this.addressResult = r.data
this.txResult = null
this.txStatus = null
this.currentAddress = address
this.query = address
this._connectSearchWs(`/ws/address/${address}`, data => {
if (!data.error) this.addressResult = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}
-9
View File
@@ -10,7 +10,6 @@ window.PageExtensions = {
tab: 'installed',
manageExtensionTab: 'releases',
filteredExtensions: [],
categories: new Set(),
updatableExtensions: [],
showUninstallDialog: false,
showManageExtensionDialog: false,
@@ -107,10 +106,6 @@ window.PageExtensions = {
}
}
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
const isInSelectedCategory = extension =>
extension.categories?.includes(tab) ?? false
this.filteredExtensions = this.extensions
.filter(e => (tab === 'all' ? !e.isInstalled : true))
.filter(e => (tab === 'installed' ? e.isInstalled : true))
@@ -118,7 +113,6 @@ window.PageExtensions = {
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
)
.filter(e => (tab === 'featured' ? e.isFeatured : true))
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
.filter(extensionNameContains(term))
.map(e => ({
...e,
@@ -838,9 +832,6 @@ window.PageExtensions = {
async fetchAllExtensions() {
try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
ext.categories?.forEach(category => this.categories.add(category))
})
return data
} catch (error) {
console.warn(error)
+1 -1
View File
@@ -21,7 +21,7 @@ window.PageHome = {
return (
this.lnurl !== '' &&
this.g.settings.allowRegister &&
this.g.settings.authMethods.includes('user-id-only')
'user-id-only' in this.g.settings.authMethods
)
},
formatDescription() {
+12 -39
View File
@@ -223,12 +223,6 @@ window.PageWallet = {
if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
this.receive.units = [
'sats',
...(this.g.allowedCurrencies.length > 0
? this.g.allowedCurrencies
: this.g.currencies)
]
} else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.tag === 'withdrawRequest') {
@@ -414,19 +408,12 @@ window.PageWallet = {
switch (action.tag) {
case 'url':
Quasar.Notify.create({
message: action.url,
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
caption: action.description,
html: false,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true,
actions: [
{
label: 'Open link',
color: 'white',
handler: () => this.utils.openUrlInNewTab(action.url)
}
]
closeBtn: true
})
break
case 'message':
@@ -438,29 +425,15 @@ window.PageWallet = {
})
break
case 'aes':
this.utils
.decryptLnurlPayAES(action, response.data.preimage)
.then(value => {
Quasar.Notify.create({
message: value,
caption: action.description,
html: false,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
.catch(error => {
Quasar.Notify.create({
message: action.description || 'Payment successful.',
caption: 'Could not decrypt success action.',
html: false,
type: 'warning',
timeout: 0,
closeBtn: true
})
})
break
this.utils.decryptLnurlPayAES(action, response.data.preimage)
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
}
}
})
-36
View File
@@ -323,20 +323,6 @@ window._lnbitsUtils = {
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
},
_extI18nDirs: new Set(),
_extI18nLoaded: {},
loadExtI18n(dir, locale) {
this._extI18nDirs.add(dir)
const loaded = (this._extI18nLoaded[dir] ??= {})
if (loaded[locale]) return loaded[locale]
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
if (locale !== 'en') {
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
return loaded['en']
}
})
return loaded[locale]
},
async decryptLnurlPayAES(success_action, preimage) {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
@@ -365,27 +351,5 @@ window._lnbitsUtils = {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
},
validateBrowsableUrl(urlString, allowLoopback = false) {
const url = new URL(urlString)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol')
}
if (!allowLoopback) {
const host = url.hostname
if (
host === 'localhost' ||
host === '[::1]' ||
host === '::1' ||
host.startsWith('127.') ||
host.startsWith('::ffff:127.')
) {
throw new Error('Loopback addresses are not allowed')
}
}
},
openUrlInNewTab(urlString, allowLoopback = false) {
this.validateBrowsableUrl(urlString, allowLoopback)
window.open(urlString, '_blank', 'noopener,noreferrer')
}
}
+2 -6
View File
@@ -58,15 +58,11 @@ body.bg-image {
}
// transparent background for specific elements
body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
}
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
.q-header,
.q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
backdrop-filter: brightness(0.8);
backdrop-filter: blur(6px) brightness(0.8);
}
}
+2 -11
View File
@@ -61,21 +61,12 @@ body.rounded-ui {
body.card-shadow {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
}
}
body.card-shadow.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
}
body.no-burger-background {
.q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
}
-3
View File
@@ -58,7 +58,6 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -71,8 +70,6 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/admin/lnbits-admin-blockexplorer.js",
"js/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",
+20776
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}
.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}
/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
direction: ltr;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}
.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}
.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}
+495 -865
View File
File diff suppressed because it is too large Load Diff
+3116 -2768
View File
File diff suppressed because it is too large Load Diff
+131 -221
View File
@@ -1,5 +1,5 @@
/*!
* qrcode.vue v3.9.0
* qrcode.vue v3.6.0
* A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3
* © 2017-PRESENT @scopewu(https://github.com/scopewu)
* MIT License.
@@ -909,18 +909,7 @@ var qrcodegen;
})(qrcodegen || (qrcodegen = {}));
var QR = qrcodegen;
var _uid = 0;
function getUid() {
if (typeof vue.useId === 'function') {
return "".concat(vue.useId(), "-").concat(_uid++);
}
return "vue-".concat(Math.random().toString(36).slice(2), "-").concat(_uid++);
}
var defaultErrorCorrectLevel = 'L';
var DEFAULT_QR_SIZE = 100;
var DEFAULT_MARGIN = 0;
var DEFAULT_IMAGE_SIZE_RATIO = 0.1;
var IMAGE_EXCAVATE_THICKNESS = 2;
var ErrorCorrectLevelMap = {
L: QR.QrCode.Ecc.LOW,
M: QR.QrCode.Ecc.MEDIUM,
@@ -940,139 +929,74 @@ var SUPPORTS_PATH2D = (function () {
function validErrorCorrectLevel(level) {
return level in ErrorCorrectLevelMap;
}
function getNeighborFlags(modules, row, col) {
var north = row > 0 ? modules[row - 1][col] : false;
var south = row < modules.length - 1 ? modules[row + 1][col] : false;
var west = col > 0 ? modules[row][col - 1] : false;
var east = col < modules[row].length - 1 ? modules[row][col + 1] : false;
return {
nw: !north && !west,
ne: !north && !east,
se: !south && !east,
sw: !south && !west,
};
}
function generateRoundedPath(modules, margin, radius) {
if (margin === void 0) { margin = 0; }
if (radius === void 0) { radius = 0; }
var pathSegments = [];
var r = Math.min(radius, 0.5);
for (var row = 0; row < modules.length; row++) {
for (var col = 0; col < modules[row].length; col++) {
if (!modules[row][col])
continue;
var _a = getNeighborFlags(modules, row, col), nw = _a.nw, ne = _a.ne, se = _a.se, sw = _a.sw;
var x = col + margin;
var y = row + margin;
pathSegments.push("M".concat(x + (nw ? r : 0), " ").concat(y), "L".concat(x + 1 - (ne ? r : 0), " ").concat(y));
if (ne) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1, " ").concat(y + r));
}
pathSegments.push("L".concat(x + 1, " ").concat(y + 1 - (se ? r : 0)));
if (se) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1 - r, " ").concat(y + 1));
}
pathSegments.push("L".concat(x + (sw ? r : 0), " ").concat(y + 1));
if (sw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x, " ").concat(y + 1 - r));
}
pathSegments.push("L".concat(x, " ").concat(y + (nw ? r : 0)));
if (nw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + r, " ").concat(y));
}
pathSegments.push('z');
}
}
return pathSegments.join('');
}
function generatePath(modules, margin) {
if (margin === void 0) { margin = 0; }
var pathSegments = [];
for (var y = 0; y < modules.length; y++) {
var row = modules[y];
var ops = [];
modules.forEach(function (row, y) {
var start = null;
for (var x = 0; x < row.length; x++) {
var cell = row[x];
row.forEach(function (cell, x) {
if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma,
pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
// saving a char per operation
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
start = null;
continue;
return;
}
// end of row, clean up or skip
if (x === row.length - 1) {
if (!cell) {
// We would have closed the op above already so this can only mean
// 2+ light modules in a row.
continue;
return;
}
if (start === null) {
// Just a single dark module.
pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
}
else {
// Otherwise finish the current line.
pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
}
continue;
return;
}
if (cell && start === null) {
start = x;
}
}
}
return pathSegments.join('');
});
});
return ops.join('');
}
function getImageSettings(cells, size, margin, imageSettings) {
var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y;
var numCells = cells.length + margin * 2;
var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO);
var defaultSize = Math.floor(size * 0.1);
var scale = numCells / size;
var w = (width || defaultSize) * scale;
var h = (height || defaultSize) * scale;
var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale;
var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale;
var borderRadius = (imageSettings.borderRadius || 0) * scale;
return { x: x, y: y, h: h, w: w, borderRadius: borderRadius };
var excavation = null;
if (imageSettings.excavate) {
var floorX = Math.floor(x);
var floorY = Math.floor(y);
var ceilW = Math.ceil(w + x - floorX);
var ceilH = Math.ceil(h + y - floorY);
excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };
}
return { x: x, y: y, h: h, w: w, excavation: excavation };
}
function useQRCode(props) {
var margin = vue.computed(function () { var _a; return ((_a = props.margin) !== null && _a !== void 0 ? _a : DEFAULT_MARGIN) >>> 0; });
var cells = vue.computed(function () {
var level = validErrorCorrectLevel(props.level) ? props.level : defaultErrorCorrectLevel;
return QR.QrCode.encodeText(props.value, ErrorCorrectLevelMap[level]).getModules();
});
var numCells = vue.computed(function () { return cells.value.length + margin.value * 2; });
var fgPath = vue.computed(function () {
if (props.radius > 0) {
return generateRoundedPath(cells.value, margin.value, props.radius);
function excavateModules(modules, excavation) {
return modules.slice().map(function (row, y) {
if (y < excavation.y || y >= excavation.y + excavation.h) {
return row;
}
return generatePath(cells.value, margin.value);
return row.map(function (cell, x) {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
});
var imageProps = vue.computed(function () {
if (!props.imageSettings.src)
return null;
var settings = getImageSettings(cells.value, props.size, margin.value, props.imageSettings);
return {
x: settings.x + margin.value,
y: settings.y + margin.value,
width: settings.w,
height: settings.h,
borderRadius: settings.borderRadius,
};
});
var imageBorderProps = vue.computed(function () {
if (!props.imageSettings.excavate || !imageProps.value)
return null;
var borderThickness = IMAGE_EXCAVATE_THICKNESS / (props.size / numCells.value);
return {
x: imageProps.value.x - borderThickness,
y: imageProps.value.y - borderThickness,
width: imageProps.value.width + borderThickness * 2,
height: imageProps.value.height + borderThickness * 2,
borderRadius: imageProps.value.borderRadius,
};
});
return { margin: margin, numCells: numCells, cells: cells, fgPath: fgPath, imageProps: imageProps, imageBorderProps: imageBorderProps };
}
var QRCodeProps = {
value: {
@@ -1082,7 +1006,7 @@ var QRCodeProps = {
},
size: {
type: Number,
default: DEFAULT_QR_SIZE,
default: 100,
},
level: {
type: String,
@@ -1100,7 +1024,7 @@ var QRCodeProps = {
margin: {
type: Number,
required: false,
default: DEFAULT_MARGIN,
default: 0,
},
imageSettings: {
type: Object,
@@ -1128,12 +1052,6 @@ var QRCodeProps = {
required: false,
default: '#fff',
},
radius: {
type: Number,
required: false,
default: 0,
validator: function (r) { return !isNaN(r) && r >= 0 && r <= 0.5; },
},
};
var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: {
type: String,
@@ -1145,11 +1063,36 @@ var QrcodeSvg = vue.defineComponent({
name: 'QRCodeSvg',
props: QRCodeProps,
setup: function (props) {
var _a = useQRCode(props), numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var uid = getUid();
var qrGradientId = "qrcode.vue-gradient-".concat(uid);
var qrLogoClipPathId = "qrcode.vue-logo-clip-path-".concat(uid);
var gradientVNode = vue.computed(function () {
var numCells = vue.ref(0);
var fgPath = vue.ref('');
var imageProps;
var generate = function () {
var value = props.value, _level = props.level, _margin = props.margin;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
numCells.value = cells.length + margin * 2;
if (props.imageSettings.src) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
fgPath.value = generatePath(cells, margin);
};
var renderGradient = function () {
if (!props.gradient)
return null;
var gradientProps = props.gradientType === 'linear'
@@ -1166,7 +1109,7 @@ var QrcodeSvg = vue.defineComponent({
fx: '50%',
fy: '50%',
};
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [
vue.h('stop', {
offset: '0%',
style: { stopColor: props.gradientStartColor },
@@ -1176,52 +1119,27 @@ var QrcodeSvg = vue.defineComponent({
style: { stopColor: props.gradientEndColor },
}),
]);
});
var clipPathVNode = vue.computed(function () {
if (!imageProps.value)
return null;
var borderRadius = imageProps.value.borderRadius;
if (borderRadius <= 0)
return null;
return vue.h('clipPath', { id: qrLogoClipPathId }, [
vue.h('rect', {
x: imageProps.value.x,
y: imageProps.value.y,
width: imageProps.value.width,
height: imageProps.value.height,
rx: borderRadius,
ry: borderRadius,
}),
]);
});
};
generate();
vue.onUpdated(generate);
return function () { return vue.h('svg', {
width: props.size,
height: props.size,
'shape-rendering': "crispEdges",
xmlns: 'http://www.w3.org/2000/svg',
viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value),
role: 'img',
'aria-label': props.value,
}, [
vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]),
vue.h('defs', {}, [renderGradient()]),
vue.h('rect', {
width: '100%',
height: '100%',
fill: props.background,
}),
vue.h('path', {
fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground,
fill: props.gradient ? 'url(#qr-gradient)' : props.foreground,
d: fgPath.value,
}),
imageBorderProps.value && vue.h('rect', {
x: imageBorderProps.value.x,
y: imageBorderProps.value.y,
width: imageBorderProps.value.width,
height: imageBorderProps.value.height,
fill: props.background,
rx: imageBorderProps.value.borderRadius,
ry: imageBorderProps.value.borderRadius,
}),
props.imageSettings.src && imageProps.value && vue.h('image', __assign(__assign({ href: props.imageSettings.src }, imageProps.value), (imageProps.value.borderRadius > 0 ? { 'clip-path': "url(#".concat(qrLogoClipPathId, ")") } : {}))),
props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)),
]); };
},
});
@@ -1229,89 +1147,81 @@ var QrcodeCanvas = vue.defineComponent({
name: 'QRCodeCanvas',
props: QRCodeProps,
setup: function (props, ctx) {
var _a = useQRCode(props), margin = _a.margin, cells = _a.cells, numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var canvasEl = vue.ref(null);
var imageEl = vue.ref(null);
var drawRoundedRect = function (ctx, x, y, width, height, radius) {
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, width, height, radius);
}
else {
ctx.rect(x, y, width, height);
}
};
var imageRef = vue.ref(null);
var generate = function () {
var size = props.size, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var value = props.value, _level = props.level, size = props.size, _margin = props.margin, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var canvas = canvasEl.value;
if (!canvas) {
return;
}
var canvasCtx = canvas.getContext('2d');
if (!canvasCtx) {
var ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
var image = imageEl.value;
var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
var scale = (size / numCells.value) * devicePixelRatio;
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
var numCells = cells.length + margin * 2;
var image = imageRef.value;
var imageProps = { x: 0, y: 0, width: 0, height: 0 };
var showImage = props.imageSettings.src && image != null && image.naturalWidth !== 0 && image.naturalHeight !== 0;
if (showImage) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
var devicePixelRatio = window.devicePixelRatio || 1;
var scale = (size / numCells) * devicePixelRatio;
canvas.height = canvas.width = size * devicePixelRatio;
canvasCtx.setTransform(scale, 0, 0, scale, 0, 0);
canvasCtx.fillStyle = background;
canvasCtx.fillRect(0, 0, numCells.value, numCells.value);
ctx.scale(scale, scale);
ctx.fillStyle = background;
ctx.fillRect(0, 0, numCells, numCells);
if (gradient) {
var grad = void 0;
if (gradientType === 'linear') {
grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value);
grad = ctx.createLinearGradient(0, 0, numCells, numCells);
}
else {
grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2);
grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2);
}
grad.addColorStop(0, gradientStartColor);
grad.addColorStop(1, gradientEndColor);
canvasCtx.fillStyle = grad;
ctx.fillStyle = grad;
}
else {
canvasCtx.fillStyle = foreground;
ctx.fillStyle = foreground;
}
if (SUPPORTS_PATH2D) {
canvasCtx.fill(new Path2D(fgPath.value));
ctx.fill(new Path2D(generatePath(cells, margin)));
}
else {
cells.value.forEach(function (row, rdx) {
cells.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) {
if (cell) {
canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1);
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
}
});
});
}
var showImage = props.imageSettings.src && image && image.naturalWidth !== 0 && image.naturalHeight !== 0;
if (showImage && imageProps.value) {
if (imageBorderProps.value) {
var imageBorder = imageBorderProps.value;
canvasCtx.fillStyle = props.background;
drawRoundedRect(canvasCtx, imageBorder.x, imageBorder.y, imageBorder.width, imageBorder.height, imageBorder.borderRadius);
canvasCtx.fill();
}
var borderRadius = imageProps.value.borderRadius;
if (borderRadius > 0) {
canvasCtx.save();
drawRoundedRect(canvasCtx, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height, borderRadius);
canvasCtx.clip();
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
canvasCtx.restore();
}
else {
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
}
if (showImage) {
ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height);
}
};
vue.onMounted(generate);
vue.watchEffect(generate);
vue.onUpdated(generate);
var style = ctx.attrs.style;
return function () { return vue.h(vue.Fragment, [
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, role: 'img', 'aria-label': props.value, style: __assign(__assign({}, ctx.attrs.style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
props.imageSettings.src && vue.h('img', {
ref: imageEl,
ref: imageRef,
src: props.imageSettings.src,
style: { display: 'none' },
onLoad: generate,
@@ -1321,23 +1231,23 @@ var QrcodeCanvas = vue.defineComponent({
});
var QrcodeVue = vue.defineComponent({
name: 'Qrcode',
props: QRCodeVueProps,
setup: function (props) {
return function () { return vue.h(props.renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
value: props.value,
size: props.size,
margin: props.margin,
level: props.level,
background: props.background,
foreground: props.foreground,
imageSettings: props.imageSettings,
gradient: props.gradient,
gradientType: props.gradientType,
gradientStartColor: props.gradientStartColor,
gradientEndColor: props.gradientEndColor,
radius: props.radius,
}); };
render: function () {
var _a = this.$props, renderAs = _a.renderAs, value = _a.value, size = _a.size, margin = _a.margin, level = _a.level, background = _a.background, foreground = _a.foreground, imageSettings = _a.imageSettings, gradient = _a.gradient, gradientType = _a.gradientType, gradientStartColor = _a.gradientStartColor, gradientEndColor = _a.gradientEndColor;
return vue.h(renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
value: value,
size: size,
margin: margin,
level: level,
background: background,
foreground: foreground,
imageSettings: imageSettings,
gradient: gradient,
gradientType: gradientType,
gradientStartColor: gradientStartColor,
gradientEndColor: gradientEndColor,
});
},
props: QRCodeVueProps,
});
exports.QrcodeCanvas = QrcodeCanvas;
+28 -15
View File
@@ -43,7 +43,8 @@ summary {
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/**
@@ -199,7 +200,8 @@ input[type=search]::-webkit-search-decoration {
.material-symbols-outlined,
.material-symbols-rounded,
.material-symbols-sharp {
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: inherit;
font-size: inherit;
display: inline-flex;
@@ -996,7 +998,8 @@ input[type=search]::-webkit-search-decoration {
height: 1px;
}
.q-checkbox__bg, .q-checkbox__icon-container {
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-checkbox__bg {
top: 25%;
@@ -2209,7 +2212,8 @@ body.q-ios-padding .q-dialog__inner > div {
width: 100%;
min-width: 0;
outline: 0 !important;
user-select: auto;
-webkit-user-select: auto;
user-select: auto;
}
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
-webkit-animation-name: q-autofill;
@@ -3035,7 +3039,8 @@ body.body--dark .q-knob--editable:focus:before {
z-index: 2001;
height: 100%;
width: 15px;
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-layout, .q-header, .q-footer, .q-page {
@@ -3292,7 +3297,8 @@ body.platform-ios .q-layout--containerized {
height: 1px;
}
.q-radio__bg, .q-radio__icon-container {
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-radio__bg {
top: 25%;
@@ -3776,7 +3782,8 @@ body.platform-ios:not(.native-mobile) .q-dialog__inner--top .q-select__dialog--f
.q-slide-item__content {
background: inherit;
transition: transform 0.2s ease-in;
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
}
@@ -4149,7 +4156,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
}
.q-splitter__separator {
background-color: rgba(0, 0, 0, 0.12);
user-select: none;
-webkit-user-select: none;
user-select: none;
position: relative;
z-index: 1;
}
@@ -4238,7 +4246,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
color: #000;
}
.q-stepper__tab--navigation {
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
}
.q-stepper__tab--active, .q-stepper__tab--done {
@@ -4470,7 +4479,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
.q-table th {
font-weight: 500;
font-size: 12px;
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-table th.sortable {
cursor: pointer;
@@ -5474,7 +5484,8 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no
width: 0.5em;
height: 0.5em;
transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
-webkit-user-select: none;
user-select: none;
z-index: 0;
}
.q-toggle__thumb:after {
@@ -10326,7 +10337,6 @@ body.body--dark .inset-shadow-down {
.glossy {
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.12) 51%, rgba(0, 0, 0, 0.04)) !important;
}
.q-placeholder::placeholder {
color: inherit;
opacity: 0.7;
@@ -10358,7 +10368,8 @@ body.body--dark .inset-shadow-down {
text-decoration: none;
}
.q-link--focusable:focus-visible {
text-decoration: underline dashed currentColor 1px;
-webkit-text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
}
body.electron .q-electron-drag {
@@ -10375,7 +10386,8 @@ img.responsive {
}
.non-selectable {
user-select: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.scroll,
@@ -11033,7 +11045,8 @@ body.q-ios-padding .fullscreen {
}
.q-touch {
user-select: none;
-webkit-user-select: none;
user-select: none;
user-drag: none;
-khtml-user-drag: none;
-webkit-user-drag: none;

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