Compare commits

..
74 Commits
Author SHA1 Message Date
dni ⚡ 63d316381e rc3 2024-10-10 09:14:35 +02:00
Tiago Vasconcelosanddni ⚡ 0e9b1b44b2 added thi one also DNI
remove it if not needed
2024-10-10 09:09:10 +02:00
Tiago Vasconcelosanddni ⚡ 9fa0ef97c1 attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-10 09:09:09 +02:00
Tiago Vasconcelosanddni ⚡ 2e8128b85d screwed up and made more css pushes to this branch 2024-10-10 09:09:07 +02:00
Tiago Vasconcelosanddni ⚡ fd0fa75e11 remove shadow on cards in dark theme 2024-10-10 09:09:06 +02:00
dni ⚡ ff12a76b42 fix dynamic fields and ext db migration 2024-10-10 09:09:05 +02:00
Vlad Stananddni ⚡ 8a023471a9 fix: component name 2024-10-10 09:09:02 +02:00
Vlad Stananddni ⚡ 0b7c33c060 chore: code format 2024-10-10 09:09:01 +02:00
Vlad Stananddni ⚡ 4d2438cf49 fix: payment extra 2024-10-10 09:09:00 +02:00
Vlad Stananddni ⚡ f590efd719 fix: payment extra 2024-10-10 09:08:58 +02:00
Vlad Stananddni ⚡ d5ea1af518 chore: code format 2024-10-10 09:08:57 +02:00
Vlad Stananddni ⚡ f227be2ea5 fix: payment extra and payment hash 2024-10-10 09:08:56 +02:00
Vlad Stananddni ⚡ 3ccdc3e249 chore: log message fix 2024-10-10 09:08:54 +02:00
dni ⚡ 7ca0a35b3a vue3 @input migration 2024-10-10 09:08:53 +02:00
dni ⚡ 0286906bd9 extension toggle vue3 2024-10-10 09:08:52 +02:00
dni ⚡ 63b499d2c9 payments 2024-10-10 09:08:51 +02:00
Vlad Stananddni ⚡ 620325b399 fix: lint 2024-10-10 09:08:49 +02:00
Vlad Stananddni ⚡ 3cfbae3b28 chore: code format 2024-10-10 09:08:48 +02:00
Vlad Stananddni ⚡ a94f42368d feat: add JSON custom serializer 2024-10-10 09:08:47 +02:00
Vlad Stananddni ⚡ 74b24eb609 fix: cast balance to int 2024-10-10 09:08:45 +02:00
Vlad Stananddni ⚡ c6ec329328 fix: time column 2024-10-10 09:08:44 +02:00
Vlad Stananddni ⚡ cd549315f1 fix: empty currency 2024-10-10 09:08:43 +02:00
dni ⚡ 28897ca120 fixup! 2024-10-10 09:08:42 +02:00
dni ⚡ e8d10abee3 add wallet balance 2024-10-10 09:08:40 +02:00
dni ⚡ 7715e9c343 use db url 2024-10-10 09:08:39 +02:00
dni ⚡ de1a780f6d escape user 2024-10-10 09:08:38 +02:00
Vlad Stananddni ⚡ e2eb07285f chore: remove verbose key names 2024-10-10 09:08:36 +02:00
Vlad Stananddni ⚡ 343e7847df chore: format 2024-10-10 09:08:35 +02:00
Vlad Stananddni ⚡ 5cb361fe43 refactor: make settings a fixture 2024-10-10 09:08:34 +02:00
Vlad Stananddni ⚡ 4906bdcc4e fix: settings 2024-10-10 09:08:33 +02:00
Vlad Stananddni ⚡ 8725e1a2b7 fix: api tests 2024-10-10 09:08:31 +02:00
Vlad Stananddni ⚡ b0e2861aef fix: add back create_user_account 2024-10-10 09:08:30 +02:00
Vlad Stananddni ⚡ 388f9946e3 fix: some tests 2024-10-10 09:08:29 +02:00
dni ⚡ ed81ca55d9 parse date from dict 2024-10-10 09:08:27 +02:00
dni ⚡ e217e58bb6 add migration for view 2024-10-10 09:08:26 +02:00
dni ⚡ 7843a00edf fixes 2024-10-10 09:08:25 +02:00
dni ⚡ 7f3abcbd07 fix 2024-10-10 09:08:24 +02:00
Vlad Stananddni ⚡ f54c78a58d fix: account insert 2024-10-10 09:08:22 +02:00
dni ⚡ f923ecdefd fixup! 2024-10-10 09:08:20 +02:00
dni ⚡ cf03d94d4d fixup! 2024-10-10 09:08:19 +02:00
dni ⚡ 5f0068bc6d fixup! 2024-10-10 09:08:17 +02:00
dni ⚡ f85fb96063 fixup migration 2024-10-10 09:08:15 +02:00
dni ⚡ 84d9551706 fixup! 2024-10-10 09:08:13 +02:00
dni ⚡ 9ae475d4a0 fixup! 2024-10-10 09:08:12 +02:00
dni ⚡ 69e67d7d64 fixup! 2024-10-10 09:08:11 +02:00
dni ⚡ 382154b04a fix some tests 2024-10-10 09:08:10 +02:00
dni ⚡ f06b29dd82 fixup! 2024-10-10 09:08:08 +02:00
dni ⚡ 6e1b746056 fixup! 2024-10-10 09:08:07 +02:00
dni ⚡ 0b9e328363 fixup! 2024-10-10 09:08:06 +02:00
dni ⚡ f341537de2 fixup! 2024-10-10 09:08:04 +02:00
dni ⚡ 3551ee14a4 backwards compatible 2024-10-10 09:08:02 +02:00
dni ⚡ 0779535a9c fix account overview 2024-10-10 09:08:01 +02:00
dni ⚡ b0701fdb65 fix error 2024-10-10 09:07:59 +02:00
dni ⚡ 955a345900 fixup! 2024-10-10 09:07:58 +02:00
dni ⚡ aa236d99ce fixup-qrcode 2024-10-10 09:07:57 +02:00
dni ⚡ 5acddb75f5 some wallet ui fixes 2024-10-10 09:07:56 +02:00
dni ⚡ 2a17d5a8ce fixup! 2024-10-10 09:07:54 +02:00
dni ⚡ 4d75545994 revert 2024-10-10 09:07:53 +02:00
dni ⚡ cc8776ac74 fixup! 2024-10-10 09:07:52 +02:00
dni ⚡ 933c494836 more fixes 2024-10-10 09:07:50 +02:00
dni ⚡ ba843b3df4 fixup! 2024-10-10 09:07:49 +02:00
dni ⚡ 03b1a4f729 extension manager 2024-10-10 09:07:48 +02:00
dni ⚡ 0c456941e5 last fixes 2024-10-10 09:07:47 +02:00
dni ⚡ 200f5c52a7 big fx part2 2024-10-10 09:07:45 +02:00
dni ⚡ 383f6ec721 fix part1 2024-10-10 09:07:44 +02:00
Vlad Stananddni ⚡ 2410c271a6 chore: code clean-up 2024-10-10 09:07:43 +02:00
dni ⚡ 85b41c4aab remove where from argument 2024-10-10 09:07:42 +02:00
dni ⚡ cfdd81d37e fixup! 2024-10-10 09:07:40 +02:00
dni ⚡ c0db3468af more docs 2024-10-10 09:07:39 +02:00
dni ⚡ e1fb82cea5 fixup! 2024-10-10 09:07:38 +02:00
dni ⚡ 06bdd61c8e fixup! 2024-10-10 09:07:36 +02:00
dni ⚡ d813544bf4 fixup! 2024-10-10 09:07:35 +02:00
dni ⚡ cae81b61a3 fixup! 2024-10-10 09:07:34 +02:00
dni ⚡ 219e8746c5 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-10 09:07:29 +02:00
116 changed files with 4122 additions and 7688 deletions
-1
View File
@@ -78,7 +78,6 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
+3 -3
View File
@@ -375,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy_http
a2enmod headers ssl proxy proxy-http
```
Create a SSL certificate with LetsEncrypt:
@@ -414,7 +414,7 @@ EOF
Restart Apache2:
```sh
service apache2 restart
service restart apache2
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -468,7 +468,7 @@ EOF
Restart nginx:
```sh
service nginx restart
service restart nginx
```
## Using https without reverse proxy
-24
View File
@@ -1,24 +0,0 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
# services
"pay_invoice",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+10 -44
View File
@@ -17,22 +17,17 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
get_db_version,
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.tasks import ( # watchdog_task
audit_queue,
killswitch_task,
purge_audit_data,
wait_for_audit_data,
wait_for_paid_invoices,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
@@ -50,10 +45,9 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
from .core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .middleware import (
AuditMiddleware,
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
@@ -154,8 +148,6 @@ def create_app() -> FastAPI:
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
@@ -232,7 +224,7 @@ async def check_installed_extensions(app: FastAPI):
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
installed_extensions = await build_all_installed_extensions_list(False)
for ext in installed_extensions:
@@ -246,7 +238,8 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
settings.deactivate_extension_paths(ext.id)
await update_installed_extension_state(ext_id=ext.id, active=False)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -266,25 +259,6 @@ async def build_all_installed_extensions_list(
installed_extensions = await get_installed_extensions()
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
continue
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in settings.lnbits_all_extensions_ids:
continue
@@ -339,7 +313,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = await get_db_version(ext.id)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
@@ -361,14 +335,8 @@ def register_custom_extensions_path():
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(extensions_dir))
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
def register_new_ext_routes(app: FastAPI) -> Callable:
@@ -427,7 +395,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
for ext in Extension.get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as exc:
@@ -440,7 +408,6 @@ def register_async_tasks(app: FastAPI):
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
create_permanent_task(wait_for_audit_data)
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
@@ -454,7 +421,6 @@ def register_async_tasks(app: FastAPI):
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
create_permanent_task(purge_audit_data)
# server logs for websocket
if settings.lnbits_admin_ui:
+9 -11
View File
@@ -17,21 +17,20 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
update_payment_status,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.models.extensions import (
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
@@ -123,7 +122,7 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_db_versions(conn))
click.echo(await get_dbversions(conn))
@db.command("cleanup-wallets")
@@ -173,10 +172,9 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts")
-2
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.extension_api import extension_router
@@ -39,7 +38,6 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
__all__ = ["core_app", "core_app_extra", "db"]
+1106
View File
File diff suppressed because it is too large Load Diff
-167
View File
@@ -1,167 +0,0 @@
from .audit import create_audit_entry
from .db_versions import (
delete_dbversion,
get_db_version,
get_db_versions,
update_migration_version,
)
from .extensions import (
create_installed_extension,
create_user_extension,
delete_installed_extension,
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_user_extension,
)
from .payments import (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_payment,
is_internal_status_success,
mark_webhook_sent,
update_payment,
update_payment_checking_id,
update_payment_extra,
)
from .settings import (
create_admin_settings,
delete_admin_settings,
get_admin_settings,
get_super_settings,
update_admin_settings,
update_super_user,
)
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
from .users import (
create_account,
delete_account,
delete_accounts_no_wallets,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_user,
get_user_from_account,
update_account,
)
from .wallets import (
create_wallet,
delete_unused_wallets,
delete_wallet,
delete_wallet_by_id,
force_delete_wallet,
get_total_balance,
get_wallet,
get_wallet_for_key,
get_wallets,
remove_deleted_wallets,
update_wallet,
)
from .webpush import (
create_webpush_subscription,
delete_webpush_subscription,
delete_webpush_subscriptions,
get_webpush_subscription,
get_webpush_subscriptions_for_user,
)
__all__ = [
# audit
"create_audit_entry",
# db_versions
"get_db_version",
"get_db_versions",
"update_migration_version",
"delete_dbversion",
# extensions
"create_installed_extension",
"create_user_extension",
"delete_installed_extension",
"drop_extension_db",
"get_installed_extension",
"get_installed_extensions",
"get_user_active_extensions_ids",
"get_user_extension",
"update_installed_extension",
"update_installed_extension_state",
"update_user_extension",
"get_user_extensions",
# payments
"DateTrunc",
"check_internal",
"create_payment",
"delete_expired_invoices",
"delete_wallet_payment",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_wallet_payment",
"is_internal_status_success",
"mark_webhook_sent",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user",
# tinyurl
"create_tinyurl",
"delete_tinyurl",
"get_tinyurl",
"get_tinyurl_by_url",
# users
"create_account",
"delete_account",
"delete_accounts_no_wallets",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_user",
"get_user_from_account",
"update_account",
# wallets
"create_wallet",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"force_delete_wallet",
"get_total_balance",
"get_wallet",
"get_wallet_for_key",
"get_wallets",
"remove_deleted_wallets",
"update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
]
-84
View File
@@ -1,84 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
[],
{},
filters=filters,
model=AuditEntry,
)
async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""",
)
async def get_count_stats(
field: str,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""",
values=filters.values(),
model=AuditCountStat,
)
return data
async def get_long_duration_stats(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""",
values=filters.values(),
model=AuditCountStat,
)
return long_duration_paths
-39
View File
@@ -1,39 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
await (conn or db).execute(
"""
INSERT INTO dbversions (db, version) VALUES (:db, :version)
ON CONFLICT (db) DO UPDATE SET version = :version
""",
{"db": db_name, "version": version},
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
""",
{"ext": ext_id},
)
-137
View File
@@ -1,137 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
)
from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE installed_extensions SET active = :active WHERE id = :ext
""",
{"ext": ext_id, "active": active},
)
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
DELETE from installed_extensions WHERE id = :ext
""",
{"ext": ext_id},
)
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert row, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
)
async def get_installed_extension(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
InstallableExtension,
)
return extension
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
where = "WHERE active = :active" if active is not None else ""
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
f"SELECT * FROM installed_extensions {where}",
values,
model=InstallableExtension,
)
return all_extensions
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
WHERE "user" = :user AND extension = :ext
""",
{"user": user_id, "ext": extension},
model=UserExtension,
)
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
{"user": user_id},
model=UserExtension,
)
async def create_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
SELECT * FROM extensions WHERE "user" = :user AND active
""",
{"user": user_id},
UserExtension,
)
return [ext.extension for ext in exts]
-385
View File
@@ -1,385 +0,0 @@
from time import time
from typing import Literal, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import DB_TYPE, SQLITE, Connection, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentFilters,
PaymentHistoryPoint,
)
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
"day": "%Y-%m-%d 00:00:00",
"month": "%Y-%m-01 00:00:00",
}
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""",
values,
Payment,
)
return row
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
Payment,
)
return payment
async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {limit}
""",
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
Payment,
)
async def get_payments_paginated(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
values: dict = {
"wallet_id": wallet_id,
"time": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
clause.append("wallet_id = :wallet_id")
if complete and pending:
pass
elif complete:
clause.append(
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
)
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
else:
pass
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
return await (conn or db).fetch_page(
"SELECT * FROM apipayments",
clause,
values,
filters=filters,
model=Payment,
)
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_payments_paginated(
wallet_id=wallet_id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
since=since,
exclude_uncheckable=exclude_uncheckable,
filters=filters,
conn=conn,
)
return page.data
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""",
{"delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""",
{"now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Optional[Connection] = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=data.fee,
extra=data.extra or {},
)
await (conn or db).insert("apipayments", payment)
return payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
)
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
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)
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
if DB_TYPE == SQLITE and group in sqlite_formats:
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
elif group in ("day", "hour", "month"):
date_trunc = f"date_trunc('{group}', time)"
else:
raise ValueError(f"Invalid group value: {group}")
values = {
"wallet_id": wallet_id,
}
where = [
f"wallet_id = :wallet_id AND (status = '{PaymentState.SUCCESS}' OR amount < 0)"
]
transactions: list[dict] = await db.fetchall(
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""",
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance,
date=row.get("date", 0),
income=row.get("income", 0),
spending=row.get("spending", 0),
),
)
balance -= row.get("income", 0) - row.get("spending", 0)
return results
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
{"checking_id": checking_id, "wallet": wallet_id},
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""",
{"hash": payment_hash},
Payment,
)
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
WHERE payment_hash = :hash
""",
{"status": status, "hash": payment_hash},
)
-120
View File
@@ -1,120 +0,0 @@
import json
from typing import Any, Optional
from loguru import logger
from lnbits.core.db import db
from lnbits.settings import (
AdminSettings,
EditableSettings,
SettingsField,
SuperSettings,
settings,
)
async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
return SuperSettings(**{"super_user": super_user_id, **data})
return None
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings()
if not sets:
return None
row_dict = dict(sets)
row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings(
is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
**row_dict,
)
return admin_settings
async def update_admin_settings(
data: EditableSettings, tag: Optional[str] = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
except Exception as exc:
logger.warning(exc)
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
assert settings, "updated super_user settings could not be retrieved"
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
data = {"super_user": super_user, **new_settings}
for key, value in data.items():
await set_settings_field(key, value)
settings = await get_super_settings()
assert settings, "created admin settings could not be retrieved"
return settings
async def get_settings_field(
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
row: dict = await db.fetchone(
"""
SELECT * FROM system_settings
WHERE id = :id AND tag = :tag
""",
{"id": id_, "tag": tag},
)
if not row:
return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
ON CONFLICT (id, tag) DO UPDATE SET value = :value
""",
{"id": id_, "value": value, "tag": tag or "core"},
)
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
if len(rows) == 0:
return None
data: dict[str, Any] = {}
for row in rows:
try:
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
except Exception as _:
logger.warning(
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
)
data.pop("super_user")
return data
-42
View File
@@ -1,42 +0,0 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
from ..models import TinyURL
async def create_tinyurl(domain: str, endless: bool, wallet: str):
tinyurl_id = shortuuid.uuid()[:8]
await db.execute(
"""
INSERT INTO tiny_url (id, url, endless, wallet)
VALUES (:tinyurl, :domain, :endless, :wallet)
""",
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
)
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
TinyURL,
)
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
return await db.fetchall(
"SELECT * FROM tiny_url WHERE url = :url",
{"url": url},
TinyURL,
)
async def delete_tinyurl(tinyurl_id: str):
await db.execute(
"DELETE FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
)
-174
View File
@@ -1,174 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db
from lnbits.db import Connection, Filters, Page
from ..models import (
Account,
AccountFilters,
AccountOverview,
User,
)
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
) -> Account:
if account:
account.validate_fields()
else:
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
await (conn or db).insert("accounts", account)
return account
async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc)
await db.update("accounts", account)
return account
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AccountOverview]:
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
accounts.pubkey,
wallets.id as wallet_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
[],
{},
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
Account,
)
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND (
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""",
{"delta": delta},
)
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username",
{"username": username},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey},
Account,
)
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email",
{"email": email},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :value or username = :value",
{"value": username_or_email},
Account,
)
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
id=account.id,
email=account.email,
username=account.username,
pubkey=account.pubkey,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
extensions=extensions,
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
has_password=account.password_hash is not None,
)
-157
View File
@@ -1,157 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.settings import settings
from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
)
await (conn or db).insert("wallets", wallet)
return wallet
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""",
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
)
async def delete_wallet_by_id(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""",
{"wallet": wallet_id, "now": now},
)
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
) = 0 AND (
(updated_at is null AND created_at < :delta)
OR updated_at < :delta
)
""",
{"delta": delta},
)
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchone(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchall(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted},
Wallet,
)
async def get_wallet_for_key(
key: str,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
Wallet,
)
async def get_total_balance(conn: Optional[Connection] = None):
result = await (conn or db).execute("SELECT SUM(balance) FROM balances")
row = result.mappings().first()
return row.get("balance", 0)
-59
View File
@@ -1,59 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
WebPushSubscription,
)
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
return await db.fetchall(
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
{"user": user},
WebPushSubscription,
)
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (:endpoint, :user, :data, :host)
""",
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
{"endpoint": endpoint},
)
return resp.rowcount
@@ -1,6 +1,5 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
@@ -8,41 +7,31 @@ from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_db_version,
get_dbversions,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_id = ext_info.id
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_id)
if installed_ext and installed_ext.meta:
ext_info.meta = ext_info.meta or ExtensionMeta()
ext_info.meta.payments = installed_ext.meta.payments
if installed_ext:
ext_info.meta = installed_ext.meta
await ext_info.download_archive()
ext_info.extract_archive()
db_version = await get_db_version(ext_id)
db_version = (await get_dbversions()).get(ext_id, 0)
await migrate_extension_database(ext_info, db_version)
# if the extensions does not exist in the installed extensions table, create it
# if it does exist, it will be activated later in the code
if not installed_ext:
await create_installed_extension(ext_info)
else:
await update_installed_extension(ext_info)
await create_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
@@ -77,8 +66,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function.
"""
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
@@ -104,38 +93,3 @@ async def stop_extension_background_work(ext_id: str) -> bool:
return False
return True
async def get_valid_extensions(
include_deactivated: Optional[bool] = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
async def get_valid_extension(
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext = await get_installed_extension(ext_id)
if not ext:
return None
if include_deactivated:
return Extension.from_installable_ext(ext)
if settings.lnbits_extensions_deactivate_all:
return None
return Extension.from_installable_ext(ext)
+56
View File
@@ -0,0 +1,56 @@
import hashlib
from typing import Any, Optional
from urllib import request
import httpx
from loguru import logger
from packaging import version
from lnbits.settings import settings
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
@@ -5,20 +5,24 @@ import hashlib
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
from typing import Any, Optional
from typing import Any, NamedTuple, Optional
import httpx
from loguru import logger
from pydantic import BaseModel
from lnbits.helpers import (
from lnbits.settings import settings
from .helpers import (
download_url,
file_hash,
github_api_get,
icon_to_github_url,
version_parse,
)
from lnbits.settings import settings
class ExplicitRelease(BaseModel):
@@ -138,12 +142,17 @@ class UserExtension(BaseModel):
return ext
class Extension(BaseModel):
class Extension(NamedTuple):
code: str
is_valid: bool
is_admin_only: bool
name: Optional[str] = None
short_description: Optional[str] = None
tile: Optional[str] = None
contributors: Optional[list[str]] = None
hidden: bool = False
migration_module: Optional[str] = None
db_name: Optional[str] = None
upgrade_hash: Optional[str] = ""
@property
@@ -166,12 +175,76 @@ class Extension(BaseModel):
return Extension(
code=ext_info.id,
is_valid=True,
is_admin_only=False, # todo: is admin only
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
)
@classmethod
def get_valid_extensions(
cls, include_deactivated: Optional[bool] = True
) -> list[Extension]:
valid_extensions = [
extension for extension in cls._extensions() if extension.is_valid
]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
@classmethod
def get_valid_extension(
cls, ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
all_extensions = cls.get_valid_extensions(include_deactivated)
return next((e for e in all_extensions if e.code == ext_id), None)
@classmethod
def _extensions(cls) -> list[Extension]:
p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True)
extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
# todo: remove this property somehow, it is too expensive
output: list[Extension] = []
for extension_folder in extension_folders:
extension_code = extension_folder.parts[-1]
try:
with open(extension_folder / "config.json") as json_file:
config = json.load(json_file)
is_valid = True
is_admin_only = extension_code in settings.lnbits_admin_extensions
except Exception:
config = {}
is_valid = False
is_admin_only = False
output.append(
Extension(
extension_code,
is_valid,
is_admin_only,
config.get("name"),
config.get("short_description"),
config.get("tile"),
config.get("contributors"),
config.get("hidden") or False,
config.get("migration_module"),
config.get("db_name"),
)
)
return output
class ExtensionRelease(BaseModel):
name: str
@@ -320,6 +393,10 @@ class InstallableExtension(BaseModel):
stars: int = 0
meta: Optional[ExtensionMeta] = None
@property
def is_admin_only(self) -> bool:
return self.id in settings.lnbits_admin_extensions
@property
def hash(self) -> str:
if self.meta and self.meta.installed_release:
@@ -352,6 +429,10 @@ class InstallableExtension(BaseModel):
return f"lnbits.extensions.{self.id}"
return self.id
@property
def module_installed(self) -> bool:
return self.module_name in sys.modules
@property
def has_installed_version(self) -> bool:
if not self.ext_dir.is_dir():
@@ -547,41 +628,6 @@ class InstallableExtension(BaseModel):
meta=meta,
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
version = config_json.get("version", "0.0")
return InstallableExtension(
id=ext_id,
name=config_json.get("name", ext_id),
active=True,
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
version=version,
archive=f"{conf_path}",
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
)
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
async def get_installable_extensions(
cls,
@@ -719,23 +765,3 @@ class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
+10 -23
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any, Optional
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
@@ -8,20 +8,17 @@ from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_dbversions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.extensions.models import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: InstallableExtension, current_version: int):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -35,18 +32,14 @@ async def migrate_extension_database(
async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
db: Connection, migrations_module: Any, db_name: str, current_version: int
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in list(migrations_module.__dict__.items()):
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if not current_version or version > current_version.version:
if version > current_version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -78,6 +71,7 @@ async def load_disabled_extension_list() -> None:
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
current_versions = await get_dbversions()
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
@@ -93,11 +87,7 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
@@ -105,10 +95,7 @@ async def migrate_databases():
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
current_version = current_versions.get(ext.id)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
+8 -108
View File
@@ -1,12 +1,9 @@
import json
from time import time
from typing import Any
from loguru import logger
from sqlalchemy.exc import OperationalError
from lnbits import bolt11
from lnbits.db import Connection
async def m000_create_migrations_table(db):
@@ -102,8 +99,9 @@ async def m002_add_fields_to_apipayments(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
import json
rows = await db.fetchall("SELECT * FROM apipayments")
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -213,7 +211,7 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
result = await db.execute(
rows = await db.fetchall(
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -224,7 +222,6 @@ async def m007_set_invoice_expiries(db):
AND time < {db.timestamp_now}
"""
)
rows = result.mappings().all()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -342,7 +339,7 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
result = await db.execute(
rows = await db.fetchall(
"""
SELECT *
FROM wallets
@@ -351,13 +348,12 @@ async def m014_set_deleted_wallets(db):
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
@@ -566,8 +562,6 @@ async def m023_add_column_column_to_apipayments(db):
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
@@ -590,97 +584,3 @@ async def m025_refresh_view(db):
GROUP BY apipayments.wallet_id
"""
)
async def m026_update_payment_table(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
result = await db.execute("SELECT * FROM apipayments")
payments = result.mappings().all()
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(payment.get("extra"))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
async def m028_update_settings(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
value TEXT,
tag TEXT NOT NULL DEFAULT 'core',
UNIQUE (id, tag)
);
"""
)
async def _insert_key_value(id_: str, value: Any):
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
""",
{"id": id_, "value": json.dumps(value), "tag": "core"},
)
row: dict = await db.fetchone("SELECT * FROM settings")
if row:
await _insert_key_value("super_user", row["super_user"])
editable_settings = json.loads(row["editable_settings"])
for key, value in editable_settings.items():
await _insert_key_value(key, value)
await db.execute("drop table settings")
async def m029_create_audit_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS audit (
component TEXT,
ip_address TEXT,
user_id TEXT,
path TEXT,
request_type TEXT,
request_method TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
+481
View File
@@ -0,0 +1,481 @@
from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
def json_custom_serialization(_, o):
if isinstance(o, datetime):
return o.isoformat()
raise TypeError(f"Object is not JSON serializable: {o}")
json.JSONEncoder.default = json_custom_serialization # type: ignore[method-assign]
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseModel):
id: str
user: str
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(BaseModel):
id: str
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
extra: UserExtra = UserExtra()
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
@property
def is_super_user(self) -> bool:
return self.id == settings.super_user
@property
def is_admin(self) -> bool:
return self.id in settings.lnbits_admin_users or self.is_super_user
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["id", "email", "username"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
id: str
last_payment: Optional[datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
email: Optional[str] = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(BaseModel):
status: str
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
expiry: Optional[datetime]
extra: Optional[dict]
webhook: Optional[str]
webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: datetime
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
-97
View File
@@ -1,97 +0,0 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .misc import (
BalanceDelta,
Callback,
ConversionData,
CoreAppExtra,
DbVersion,
SimpleStatus,
)
from .payments import (
CreateInvoice,
CreatePayment,
DecodePayment,
PayInvoice,
Payment,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentState,
)
from .tinyurl import TinyURL
from .users import (
AccessTokenPayload,
Account,
AccountFilters,
AccountOverview,
CreateTopup,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
# audit
"AuditEntry",
"AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta",
"Callback",
"ConversionData",
"CoreAppExtra",
"DbVersion",
"SimpleStatus",
# payments
"CreateInvoice",
"CreatePayment",
"DecodePayment",
"PayInvoice",
"Payment",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
# tinyurl
"TinyURL",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"CreateTopup",
"CreateUser",
"RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet",
"WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription",
]
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.settings import settings
class AuditEntry(BaseModel):
component: Optional[str] = None
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_type: Optional[str] = None
request_method: Optional[str] = None
request_details: Optional[str] = None
response_code: Optional[str] = None
duration: float
delete_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
class AuditFilters(FilterModel):
__search_fields__ = [
"ip_address",
"user_id",
"path",
"request_method",
"response_code",
"component",
]
__sort_fields__ = [
"created_at",
"duration",
]
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_method: Optional[str] = None
response_code: Optional[str] = None
component: Optional[str] = None
class AuditCountStat(BaseModel):
field: str
total: float
class AuditStats(BaseModel):
request_method: list[AuditCountStat] = []
response_code: list[AuditCountStat] = []
component: list[AuditCountStat] = []
long_duration: list[AuditCountStat] = []
-20
View File
@@ -1,20 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateLnurlAuth(BaseModel):
callback: str
class PayLnurlWData(BaseModel):
lnurl_w: str
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
from typing import Callable
from pydantic import BaseModel
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
-176
View File
@@ -1,176 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class PaymentExtra(BaseModel):
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
class PayInvoice(BaseModel):
payment_request: str
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_hash: str
bolt11: str
amount_msat: int
memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
bolt11: str
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = None
tag: Optional[str] = None
extension: Optional[str] = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
extra: dict = {}
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
-9
View File
@@ -1,9 +0,0 @@
from pydantic import BaseModel
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
-200
View File
@@ -1,200 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
from lnbits.settings import settings
from .wallets import Wallet
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(BaseModel):
id: str
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
def validate_fields(self):
if self.username and not is_valid_username(self.username):
raise ValueError("Invalid username.")
if self.email and not is_valid_email_address(self.email):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
email: Optional[str] = None
user: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
wallet_id: Optional[str] = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class RegisterUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: Optional[str] = Query(default=None)
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=None, min_length=2, max_length=20)
password: Optional[str] = Query(default=None, min_length=8, max_length=50)
password_repeat: Optional[str] = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
extensions: Optional[list[str]] = None
extra: Optional[UserExtra] = None
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class CreateTopup(BaseModel):
id: str
amount: int
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseModel):
id: str
user: str
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: Optional[str] = None
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
-15
View File
@@ -1,15 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: datetime
+935
View File
@@ -0,0 +1,935 @@
import asyncio
import json
import time
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.core.extensions.models import UserExtension
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
check_user_extension_access,
require_admin_key,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import (
EditableSettings,
SuperSettings,
readonly_variables,
send_admin_user_to_saas,
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from .crud import (
check_internal,
check_internal_status,
create_account,
create_admin_settings,
create_payment,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_user,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import (
Account,
BalanceDelta,
CreatePayment,
Payment,
PaymentState,
User,
UserExtra,
Wallet,
)
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[dict] = None,
conn: Optional[Connection] = None,
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, extra
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> tuple[str, str]:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return invoice.payment_hash, payment_request
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if await check_internal_status(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
else:
new_payment = await _create_external_payment(
temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
)
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, extra["tag"])
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
)
return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection],
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = settings.lnbits_reserve_fee_min
reserve_percent = settings.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
def service_fee(amount_msat: int, internal: bool = False) -> int:
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.inkey,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
async def new_account(account_id: str) -> Account:
now = datetime.now()
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
created_at=now,
updated_at=now,
)
await create_account(account)
return account
account = None
if super_user:
account = await get_account(super_user)
if not account:
account = await new_account(super_user or uuid4().hex)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if account:
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if account.id:
user_uuid4 = UUID(hex=account.id, version=4)
assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string"
else:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user(account)
assert user, "Cannot find user for account."
return user
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
-65
View File
@@ -1,65 +0,0 @@
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .payments import (
calculate_fiat_amounts,
check_transaction_status,
check_wallet_limits,
create_invoice,
fee_reserve,
fee_reserve_total,
pay_invoice,
send_payment_notification,
service_fee,
update_pending_payments,
update_wallet_balance,
)
from .settings import (
check_webpush_settings,
update_cached_settings,
)
from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from .websockets import websocket_manager, websocket_updater
__all__ = [
# funding source
"get_balance_delta",
"switch_to_voidwallet",
# lnurl
"redeem_lnurl_withdraw",
"perform_lnurlauth",
# payments
"calculate_fiat_amounts",
"check_transaction_status",
"check_wallet_limits",
"create_invoice",
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"send_payment_notification",
"service_fee",
"update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings",
"update_cached_settings",
# users
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
"websocket_manager",
"websocket_updater",
]
-23
View File
@@ -1,23 +0,0 @@
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
from ..crud import get_total_balance
from ..models import BalanceDelta
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
-155
View File
@@ -1,155 +0,0 @@
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
import httpx
from fastapi import Depends
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings
from .payments import create_invoice
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
-574
View File
@@ -1,574 +0,0 @@
import json
import time
from typing import Optional
from bolt11 import decode as bolt11_decode
from bolt11.types import Bolt11
from loguru import logger
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from ..crud import (
check_internal,
create_payment,
get_payments,
get_standalone_payment,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_payment,
)
from ..models import (
CreatePayment,
Payment,
PaymentState,
Wallet,
)
from .websockets import websocket_manager
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> Payment:
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
if await is_internal_status_success(invoice.payment_hash, conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
await _credit_service_fee_wallet(payment, conn)
return payment
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, user_wallet, currency, extra
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return payment
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
return settings.fee_reserve(amount_msat, internal)
def service_fee(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def send_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
await websocket_manager.send_data(
json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
),
wallet.inkey,
)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
if currency != wallet_currency:
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
return amount_sat, fiat_amounts
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
"""
# check_internal() returns the payment of the invoice we're waiting for
# (pending only)
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
amount_msat = create_payment_model.amount_msat
if (
internal_invoice.amount != abs(amount_msat)
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await send_payment_notification(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(checking_id, conn=conn)
if old_payment:
return await _verify_external_payment(old_payment, conn)
create_payment_model.fee = -abs(fee_reserve_total_msat)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
funding_source = get_funding_source()
logger.debug(f"fundingsource: sending payment {checking_id}")
payment_response: PaymentResponse = await funding_source.pay_invoice(
create_payment_model.bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.checking_id})"
)
if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
return payment
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if payment.success:
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
# status.pending fall through and try again
return payment
async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection] = None,
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, tag, conn=conn)
if not status.success:
raise PaymentError(status.message)
await check_wallet_limits(wallet_id, amount_msat, conn)
return wallet
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
return invoice
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
-50
View File
@@ -1,50 +0,0 @@
from cryptography.hazmat.primitives import serialization
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.settings import (
EditableSettings,
readonly_variables,
settings,
)
from ..crud import update_admin_settings
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
if settings.lnbits_admin_ui:
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
-189
View File
@@ -1,189 +0,0 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
from ..crud import (
create_account,
create_admin_settings,
create_user_extension,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_super_settings,
get_user_extensions,
get_user_from_account,
update_account,
update_super_user,
update_user_extension,
)
from ..helpers import to_valid_user_id
from ..models import (
Account,
User,
UserExtra,
)
from .settings import update_cached_settings
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
return await create_user_account_no_ckeck(account, wallet_name)
async def create_user_account_no_ckeck(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if account:
account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if not account.id:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
return user
async def update_user_account(account: Account) -> Account:
account.validate_fields()
existing_account = await get_account(account.id)
if not existing_account:
raise ValueError("User does not exist.")
account.password_hash = existing_account.password_hash
if existing_account.username and not account.username:
raise ValueError("Cannot remove username.")
if account.username:
existing_account = await get_account_by_username(account.username)
if existing_account and existing_account.id != account.id:
raise ValueError("Username already exists.")
elif existing_account.username:
raise ValueError("Cannot remove username.")
if account.email:
existing_account = await get_account_by_email(account.email)
if existing_account and existing_account.id != account.id:
raise ValueError("Email already exists.")
if account.pubkey:
existing_account = await get_account_by_pubkey(account.pubkey)
if existing_account and existing_account.id != account.id:
raise ValueError("Pubkey already exists.")
return await update_account(account)
async def update_user_extensions(user_id: str, extensions: list[str]):
user_extensions = await get_user_extensions(user_id)
for user_ext in user_extensions:
if user_ext.active:
if user_ext.extension not in extensions:
user_ext.active = False
await update_user_extension(user_ext)
else:
if user_ext.extension in extensions:
user_ext.active = True
await update_user_extension(user_ext)
user_extension_ids = [ue.extension for ue in user_extensions]
for ext in extensions:
if ext in user_extension_ids:
continue
user_extension = UserExtension(user=user_id, extension=ext, active=True)
await create_user_extension(user_extension)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
-27
View File
@@ -1,27 +0,0 @@
from fastapi import WebSocket
from loguru import logger
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
+1 -32
View File
@@ -5,13 +5,11 @@ import httpx
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.models import AuditEntry, Payment
from lnbits.core.models import Payment
from lnbits.core.services import (
get_balance_delta,
send_payment_notification,
@@ -21,7 +19,6 @@ from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
audit_queue: asyncio.Queue = asyncio.Queue()
async def killswitch_task():
@@ -160,31 +157,3 @@ async def send_payment_push_notification(payment: Payment):
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
async def wait_for_audit_data():
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data: AuditEntry = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def purge_audit_data():
"""
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)
-212
View File
@@ -1,212 +0,0 @@
<q-tab-panel name="audit">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Enable audit</q-item-label>
<q-item-label caption
>Record HTTP requests according with the specified
filters</q-item-label
>
</q-item-section>
</q-item>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.lnbits_audit_retention_days"
type="number"
label="Retention days"
hint="Number of days to keep the audit entry."
>
</q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_request_body"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Request Body</q-item-label>
<q-item-label caption
>Warning:
<ul>
<li>confidential data (like passwords) will be logged</li>
<li>the request body can have large size.</li>
</ul>
Use it with caution.
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_ip_address"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record IP Address</q-item-label>
<q-item-label caption>Save the client IP address.</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_path_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Path Parameters</q-item-label>
<q-item-label caption>Recommended. </q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_query_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Query Parameters</q-item-label>
<q-item-label caption>Recommended.</q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include HTTP Methods</p>
<q-select
filled
v-model="formData.lnbits_audit_http_methods"
multiple
hint="List of HTTP methods to be included. Empty lists means all."
label="HTTP Methods"
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
></q-select>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include HTTP Response Codes</p>
<q-input
filled
v-model="formAddIncludeResponseCode"
@keydown.enter="addIncludeResponseCode"
type="text"
label="HTTP Response code (regex)"
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="code in formData.lnbits_audit_http_response_codes"
:key="code"
removable
@remove="removeIncludeResponseCode(code)"
color="primary"
text-color="white"
:label="code"
>
</q-chip>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include Paths</p>
<q-input
filled
v-model="formAddIncludePath"
@keydown.enter="addIncludePath"
type="text"
label="HTTP Path (regex)"
hint="List of paths to be included (regex match). Empty list means all."
>
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_include_paths"
:key="path"
removable
@remove="removeIncludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>Exclude Paths</p>
<q-input
filled
v-model="formAddExcludePath"
@keydown.enter="addExcludePath"
type="text"
label="HTTP Path (regex)"
hint="List of paths to be excluded (regex match). Empty list means none."
>
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_exclude_paths"
:key="path"
removable
@remove="removeExcludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,94 +0,0 @@
<q-tab-panel name="extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">Extensions</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,140 +0,0 @@
<q-tab-panel name="notifications">
<q-card-section class="q-pa-none">
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+153 -30
View File
@@ -1,20 +1,8 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<div class="row">
<div class="col-md-6">
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none q-mb-sm">Authentication</h6>
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6">
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.auth_token_expire_minutes"
@@ -24,7 +12,7 @@
>
</q-input>
</div>
<div class="col-12 col-sm-6">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-select
filled
v-model="formData.auth_allowed_methods"
@@ -43,9 +31,8 @@
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-12">
<div class="col-md-12 col-sm-12 q-pr-sm">
<q-input
class="q-mb-sm"
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
@@ -56,17 +43,18 @@
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div class="col-12">
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
class="ellipsis"
></q-chip>
<div>
<div>
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
></q-chip>
</div>
</div>
</div>
</q-card-section>
@@ -220,7 +208,6 @@
color="primary"
text-color="white"
:label="blocked_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -250,7 +237,6 @@
color="primary"
text-color="white"
:label="allowed_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -311,5 +297,142 @@
</div>
</div>
</div>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -22,7 +22,7 @@
<q-table
dense
flat
:rows="statusData.notifications"
:data="statusData.notifications"
:columns="statusDataTable.columns"
:no-data-label="$t('no_notifications')"
>
@@ -1,6 +1,18 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<div>
<div class="row">
<div class="col-md-6">
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
@@ -88,6 +100,95 @@
<br />
</div>
</div>
<q-separator></q-separator>
<h6 class="q-my-none">Extensions</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+1 -1
View File
@@ -1,4 +1,4 @@
<q-tab-panel name="site_customisation">
<q-tab-panel name="theme">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">UI Management</h6>
<br />
+2 -4
View File
@@ -3,7 +3,7 @@
<h6 class="q-my-none q-mb-sm">User Management</h6>
<div class="row">
<div class="col-12 col-md-6 q-pr-sm">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Admin Users</p>
<q-input
filled
@@ -24,13 +24,12 @@
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<div class="col-md-6 col-sm-12">
<p>Allowed Users</p>
<q-input
filled
@@ -51,7 +50,6 @@
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
+23 -71
View File
@@ -61,98 +61,50 @@
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
:label="$t('funding')"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
:label="$t('users')"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
:label="$t('server')"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
name="security"
:label="$t('security')"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
name="theme"
:label="$t('theme')"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
</div>
</div>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
</q-tab-panels>
</q-form>
</template>
</q-splitter>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_security.html" %} {% include
"admin/_tab_theme.html" %}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
-173
View File
@@ -1,173 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center q-mb-xl">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>Components</strong>
<div style="width: 250px" class="q-pa-sm">
<canvas ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>To 5 Long Running Endpoints</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>HTTP Request Methods</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>HTTP Response Codes</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip>Request Details</q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>HTTP Request Details</strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/audit.js') }}"></script>
{% endblock %}
+18 -35
View File
@@ -46,7 +46,7 @@
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
@click="copyText('{{ wallet.adminkey }}')"
></q-icon>
</div>
</q-item-section>
@@ -85,7 +85,7 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -95,13 +95,12 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="wallet.inkey"></i>"</code
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
@@ -112,7 +111,7 @@
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -128,10 +127,9 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -144,23 +142,9 @@
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
@@ -169,10 +153,10 @@
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -197,7 +181,7 @@
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
@@ -225,10 +209,9 @@
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
-17
View File
@@ -452,23 +452,6 @@
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('border_choices')"></span>
</div>
<div class="col-8">
<q-select
v-model="borderChoice"
:options="borderOptions"
label="Reactions"
@update:model-value="applyBorder"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
></q-tooltip>
</q-select>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">Notifications</div>
<div class="col-8">
+1 -8
View File
@@ -105,6 +105,7 @@
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') "
color="secondary"
style=""
v-model="extension.isActive"
@update:model-value="toggleExtension(extension)"
><q-tooltip>
@@ -1023,10 +1024,6 @@
})
if (this.uninstallAndDropDb) {
this.showDropDb()
} else {
setTimeout(() => {
window.location.reload()
}, 300)
}
})
.catch(err => {
@@ -1055,9 +1052,6 @@
type: 'positive',
message: 'Extension DB deleted!'
})
setTimeout(() => {
window.location.reload()
}, 300)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@@ -1080,7 +1074,6 @@
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.isActive = false
extension.inProgress = false
})
},
+31 -71
View File
@@ -36,7 +36,7 @@
<q-card-section>
<h3 class="q-my-none text-no-wrap">
<strong v-text="formattedBalance"></strong>
<small> {{LNBITS_DENOMINATION}}</small>
<small>{{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance
:wallet_id="this.g.wallet.id"
flat
@@ -101,25 +101,11 @@
</div>
</div>
</q-card>
<q-card
:style="
$q.screen.lt.md
? {
background: $q.screen.lt.md ? 'none !important' : '',
boxShadow: $q.screen.lt.md ? 'none !important' : '',
marginTop: $q.screen.lt.md ? '0px !important' : ''
}
: ''
"
>
<q-card-section>
<payment-list
:update="updatePayments"
:wallet="this.g.wallet"
:mobile-simple="mobileSimple"
/>
</q-card-section>
</q-card>
<payment-list
:update="updatePayments"
:wallet="this.g.wallet"
:mobile-simple="mobileSimple"
/>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
@@ -132,7 +118,7 @@
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet:
<strong><em>{{wallet_name}}</em></strong>
<strong><em>{{wallet.name}}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
@@ -169,13 +155,12 @@
<p v-text="$t('export_to_phone_desc')"></p>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText(exportUrl)"
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
></q-btn>
</q-card-actions>
</q-card>
@@ -194,6 +179,7 @@
v-model.trim="update.name"
label="Name"
dense
@update:model-value="(e) => console.log(e)"
/>
</div>
<q-btn
@@ -295,11 +281,7 @@
</div>
</div>
<q-dialog
v-model="receive.show"
position="top"
@hide="onReceiveDialogHide"
>
<q-dialog v-model="receive.show" position="top">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
@@ -389,23 +371,6 @@
></lnbits-qrcode>
</a>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
</h3>
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span>
</h5>
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar
icon="nfc"
color="positive"
text-color="white"
></q-avatar>
NFC supported
</q-chip>
<span v-else class="text-caption text-grey">NFC not supported</span>
</div>
<div class="row q-mt-lg">
<q-btn
outline
@@ -656,8 +621,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@detect="decodeQR"
@camera-on="onInitQR"
@decode="decodeQR"
@init="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@@ -680,8 +645,8 @@
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
@camera-on="onInitQR"
@decode="decodeQR"
@init="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
@@ -696,28 +661,22 @@
</div>
</q-card>
</q-dialog>
<div
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
>
<q-tabs
active-class="px-0"
indicator-color="transparent"
align="justify"
>
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
>
</q-tab>
<q-tab
@click="showParseDialog"
icon="file_upload"
:label="$t('send')"
>
</q-tab>
</q-tabs>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
align="justify"
>
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
>
</q-tab>
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
</q-tab>
<q-btn
round
size="35px"
@@ -727,7 +686,8 @@
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
>
</q-btn>
</div>
</q-tabs>
<q-dialog v-model="disclaimerDialog.show" position="top">
<q-card class="q-pa-lg">
<h6
@@ -160,7 +160,7 @@
<q-table
dense
flat
:rows="this.filteredChannels"
:data="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
@@ -239,7 +239,7 @@
<q-table
dense
flat
:rows="peers.data"
:data="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
+7 -6
View File
@@ -42,7 +42,11 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
window.app = Vue.createApp({
el: '#vue',
config: {
@@ -363,13 +367,10 @@
this.transactionDetailsDialog.data = details
console.log('details', details)
},
shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
exportCSV: function () {},
shortenNodeId
}
})
</script>
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
{% endblock %}
@@ -0,0 +1,23 @@
<q-dialog v-model="createUserDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<p>Create User</p>
<div class="row">
<div class="col-12">
<q-form @submit="createUser">
<lnbits-dynamic-fields
:options="createUserDialog.fields"
v-model="createUserDialog.data"
></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</div>
</div>
</q-card>
</q-dialog>
@@ -1,43 +1,22 @@
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<q-dialog v-model="createWalletDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<p>Create Wallet</p>
<div class="row">
<div class="col-12">
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
<q-form @submit="createWallet">
<lnbits-dynamic-fields
:options="createWalletDialog.fields"
v-model="createUserDialog.data"
></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</div>
</div>
</q-card>
@@ -1,187 +0,0 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="$t('pubkey')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>
@@ -1,37 +1,11 @@
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
class="q-ml-md"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-dialog v-model="walletDialog.show">
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-dialog v-model="paymentDialog.show">
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
<payment-list :wallet="activeWallet" />
</q-card>
</q-dialog>
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
@@ -48,13 +22,6 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
:label="$t('topup')"
@click="showTopupDialog(props.row.id)"
color="secondary"
size="sm"
class="q-mr-md"
></q-btn>
<q-btn
round
icon="menu"
@@ -64,7 +31,23 @@
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
v-if="!props.row.deleted"
round
icon="content_copy"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<lnbits-update-balance
v-if="!props.row.deleted"
:wallet_id="props.row.id"
:callback="topupCallback"
class="q-ml-xs"
></lnbits-update-balance>
<q-btn
round
v-if="!props.row.deleted"
@@ -87,30 +70,6 @@
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
<span v-text="props.row.name"></span>
<q-btn
round
v-if="props.row.deleted"
@@ -122,28 +81,32 @@
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
round
icon="delete"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.name"></q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td auto-width v-text="props.row.deleted"></q-td>
</q-tr>
</template>
</q-table>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</q-card>
</div>
</q-dialog>
+80 -101
View File
@@ -1,25 +1,28 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}{% include "users/_topupDialog.html" %}
%} {% block page %} {% include "users/_walletDialog.html" %} {% include
"users/_topupDialog.html" %} {% include "users/_createUserDialog.html" %} {%
include "users/_createWalletDialog.html" %}
<h3 class="text-subtitle q-my-none" v-text="$t('users')"></h3>
<div class="row q-col-gutter-md justify-center">
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
<div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 100%; max-width: 2000px">
<canvas ref="chart1"></canvas>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<q-btn
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
class="q-mb-md"
>
</q-btn>
<q-card class="q-pa-md">
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<q-btn :label="$t('topup')" @click="topupDialog.show = true">
<q-tooltip
>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip
>
</q-btn>
</div>
<q-table
row-key="id"
:rows="users"
@@ -33,118 +36,94 @@
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<span v-else v-text="col.label"></span>
</q-th>
<q-th
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
round
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
round
v-if="!props.row.is_super_user"
icon="build"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
:color="props.row.is_admin ? 'primary' : 'grey'"
class="q-ml-xs"
@click="toggleAdmin(props.row.id)"
>
<q-tooltip>Copy Public Key</q-tooltip>
<q-tooltip>Toggle Admin</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.is_super_user"
icon="build"
size="sm"
color="positive"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
<q-btn
round
icon="refresh"
size="sm"
color="secondary"
@click="resetPassword(props.row.id)"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUser(props.row.id, props)"
>
<q-tooltip>Delete User</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
<q-td
auto-width
v-text="formatSat(props.row.balance_msat)"
></q-td>
<q-td auto-width v-text="props.row.wallet_count"></q-td>
<q-td auto-width v-text="props.row.transaction_count"></q-td>
<q-td auto-width v-text="props.row.username"></q-td>
<q-td auto-width v-text="props.row.email"></q-td>
<q-td
auto-width
v-text="formatDate(props.row.last_payment)"
></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</q-card-section>
</q-card>
</div>
</div>
+1 -29
View File
@@ -3,7 +3,6 @@ import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
@@ -15,7 +14,6 @@ from fastapi import (
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.models import (
BaseWallet,
ConversionData,
@@ -38,8 +36,6 @@ from lnbits.utils.exchange_rates import (
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth
@@ -51,34 +47,10 @@ async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
"version": settings.version,
}
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
user = await get_user(wallet.wallet.user)
if not user:
return stat
stat["version"] = settings.version
if not user.admin:
return stat
funding_source = get_funding_source()
stat["funding_source"] = funding_source.__class__.__name__
status: StatusResponse = await funding_source.status()
stat["funding_source_error"] = status.error_message
stat["funding_source_balance_msat"] = status.balance_msat
return stat
@api_router.get(
"/api/v1/wallets",
name="Wallets",
-49
View File
@@ -1,49 +0,0 @@
from fastapi import APIRouter, Depends
from lnbits.core.crud.audit import (
get_audit_entries,
get_count_stats,
get_long_duration_stats,
)
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditStats
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, parse_filters
from lnbits.helpers import generate_filter_params_openapi
audit_router = APIRouter(
prefix="/audit/api/v1", dependencies=[Depends(check_admin)], tags=["Audit"]
)
@audit_router.get(
"",
name="Get audit entries",
summary="Get paginated list audit entries",
openapi_extra=generate_filter_params_openapi(AuditFilters),
)
async def api_get_audit(
filters: Filters = Depends(parse_filters(AuditFilters)),
) -> Page[AuditEntry]:
return await get_audit_entries(filters)
@audit_router.get(
"/stats",
name="Get audit entries",
summary="Get paginated list audit entries",
openapi_extra=generate_filter_params_openapi(AuditFilters),
)
async def api_get_audit_stats(
filters: Filters = Depends(parse_filters(AuditFilters)),
) -> AuditStats:
request_mothod_stats = await get_count_stats("request_method", filters)
response_code_stats = await get_count_stats("response_code", filters)
components_stats = await get_count_stats("component", filters)
long_duration_stats = await get_long_duration_stats(filters)
return AuditStats(
request_method=request_mothod_stats,
response_code=response_code_stats,
component=components_stats,
long_duration=long_duration_stats,
)
+7 -7
View File
@@ -29,15 +29,15 @@ from ..crud import (
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_user_from_account,
get_user,
update_account,
)
from ..models import (
AccessTokenPayload,
Account,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
@@ -145,7 +145,7 @@ async def logout() -> JSONResponse:
@auth_router.post("/register")
async def register(data: RegisterUser) -> JSONResponse:
async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
@@ -199,7 +199,7 @@ async def update_pubkey(
account.pubkey = normalize_public_key(data.pubkey)
await update_account(account)
return await get_user_from_account(account)
return await get_user(account)
@auth_router.put("/password")
@@ -228,7 +228,7 @@ async def update_password(
account.username = data.username
account.hash_password(data.password)
await update_account(account)
_user = await get_user_from_account(account)
_user = await get_user(account)
if not _user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return _user
@@ -306,7 +306,7 @@ async def update(
account.extra = data.extra
await update_account(account)
return await get_user_from_account(account)
return await get_user(account)
@auth_router.put("/first_install")
@@ -410,7 +410,7 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
def _find_auth_provider_class(provider: str) -> Callable:
sso_modules = ["lnbits.core.models.sso", "fastapi_sso.sso"]
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
for module in sso_modules:
try:
provider_module = importlib.import_module(f"{module}.{provider}")
+21 -41
View File
@@ -1,5 +1,3 @@
import sys
import traceback
from http import HTTPStatus
from bolt11 import decode as bolt11_decode
@@ -10,12 +8,13 @@ from fastapi import (
)
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.models import (
SimpleStatus,
User,
from lnbits.core.extensions.extension_manager import (
activate_extension,
deactivate_extension,
install_extension,
uninstall_extension,
)
from lnbits.core.models.extensions import (
from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
@@ -27,15 +26,11 @@ from lnbits.core.models.extensions import (
UserExtension,
UserExtensionInfo,
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
activate_extension,
deactivate_extension,
get_valid_extension,
get_valid_extensions,
install_extension,
uninstall_extension,
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import (
check_admin,
check_user_exists,
@@ -45,7 +40,7 @@ from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_db_version,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
@@ -89,8 +84,6 @@ async def api_install_extension(data: CreateExtension):
except Exception as exc:
logger.warning(exc)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
ext_info.clean_extension_files()
detail = (
str(exc)
@@ -171,7 +164,7 @@ async def api_update_pay_to_enable(
async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
@@ -239,7 +232,7 @@ async def api_enable_extension(
async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
@@ -259,7 +252,7 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Activating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
await activate_extension(ext)
@@ -278,7 +271,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Deactivating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
await deactivate_extension(ext_id)
@@ -303,7 +296,7 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
# check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in await get_valid_extensions()]:
for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
)
@@ -437,7 +430,7 @@ async def get_pay_to_enable_invoice(
),
)
payment = await create_invoice(
payment_hash, payment_request = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
@@ -448,15 +441,15 @@ async def get_pay_to_enable_invoice(
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash
user_ext_info.payment_hash_to_enable = payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
return {"payment_hash": payment_hash, "payment_request": payment_request}
@extension_router.get(
"/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_user_exists)],
dependencies=[Depends(check_admin)],
)
async def get_extension_release(org: str, repo: str, tag_name: str):
try:
@@ -475,26 +468,13 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
) from exc
@extension_router.get("")
async def api_get_user_extensions(
user: User = Depends(check_user_exists),
) -> list[Extension]:
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
return [
ext
for ext in await get_valid_extensions(False)
if ext.code in user_extensions_ids
]
@extension_router.delete(
"/{ext_id}/db",
dependencies=[Depends(check_admin)],
)
async def delete_extension_db(ext_id: str):
try:
db_version = await get_db_version(ext_id)
db_version = (await get_dbversions()).get(ext_id, None)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+5 -23
View File
@@ -11,11 +11,10 @@ from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account
from lnbits.core.services.extensions import get_valid_extensions
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -24,7 +23,7 @@ from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_db_versions,
get_dbversions,
get_installed_extensions,
get_user,
get_wallet,
@@ -103,10 +102,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
db_version = await get_dbversions()
extensions = [
{
"id": ext.id,
@@ -117,9 +115,7 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
"isFeatured": ext.meta.featured if ext.meta else False,
"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
),
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
@@ -385,20 +381,6 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
)
@generic_router.get("/audit", response_class=HTMLResponse)
async def audit_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_audit_enabled:
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
return template_renderer().TemplateResponse(
"audit/index.html",
{
"request": request,
"user": user.json(),
},
)
@generic_router.get("/uuidv4/{hex_value}")
async def hex_to_uuid4(hex_value: str):
try:
+66 -90
View File
@@ -3,12 +3,13 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional
from typing import List, Optional, Union
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
@@ -20,12 +21,12 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@@ -120,7 +121,7 @@ async def api_payments_paginated(
return page
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
@@ -144,42 +145,60 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
# lnurl_response is not saved in the database
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
params={
"pr": payment_request,
},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
lnurl_response = resp["reason"]
else:
payment.extra["lnurl_response"] = True
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
lnurl_response = False
return payment
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
@payment_router.post(
@@ -201,25 +220,30 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment:
invoice_data: CreateInvoice = Body(...),
):
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Missing BOLT11 invoice",
detail="BOLT11 string is invalid or not given",
)
payment = await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return payment
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
elif not invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
return await api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -245,7 +269,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
):
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
@@ -289,12 +313,15 @@ async def api_payments_pay_lnurl(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
@@ -303,14 +330,19 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment = await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@@ -407,59 +439,3 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
{"message": f"Failed to decode: {exc!s}"},
status_code=HTTPStatus.BAD_REQUEST,
)
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
+19 -148
View File
@@ -2,59 +2,39 @@ import base64
import json
import time
from http import HTTPStatus
from typing import List, Optional
from uuid import uuid4
from typing import List
import shortuuid
from fastapi import APIRouter, Body, Depends
from fastapi import APIRouter, Depends
from fastapi.exceptions import HTTPException
from lnbits.core.crud import (
create_wallet,
delete_account,
delete_wallet,
force_delete_wallet,
get_accounts,
get_user,
get_wallet,
get_wallets,
update_admin_settings,
update_wallet,
)
from lnbits.core.models import (
AccountFilters,
AccountOverview,
CreateTopup,
CreateUser,
SimpleStatus,
User,
UserExtra,
Wallet,
)
from lnbits.core.models.users import Account
from lnbits.core.services import (
create_user_account_no_ckeck,
update_user_account,
update_user_extensions,
update_wallet_balance,
)
from lnbits.core.services import update_wallet_balance
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import (
encrypt_internal_message,
generate_filter_params_openapi,
)
from lnbits.helpers import encrypt_internal_message, generate_filter_params_openapi
from lnbits.settings import EditableSettings, settings
from lnbits.utils.exchange_rates import allowed_currencies
users_router = APIRouter(
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
)
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
@users_router.get(
"/user",
name="Get accounts",
name="get accounts",
summary="Get paginated list of accounts",
openapi_extra=generate_filter_params_openapi(AccountFilters),
)
@@ -64,80 +44,10 @@ async def api_get_users(
return await get_accounts(filters=filters)
@users_router.get(
"/user/{user_id}",
name="Get user",
summary="Get user by Id",
)
async def api_get_user(user_id: str) -> User:
user = await get_user(user_id)
if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user
@users_router.post("/user", name="Create user")
async def api_create_user(data: CreateUser) -> CreateUser:
if not data.username and data.password:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Username required when password provided."
)
if data.password != data.password_repeat:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
if not data.password:
random_password = shortuuid.uuid()
data.password = random_password
data.password_repeat = random_password
data.extra = data.extra or UserExtra()
data.extra.provider = data.extra.provider or "lnbits"
account = Account(
id=uuid4().hex,
username=data.username,
email=data.email,
pubkey=data.pubkey,
extra=data.extra,
)
account.validate_fields()
account.hash_password(data.password)
user = await create_user_account_no_ckeck(account)
data.id = user.id
return data
@users_router.put("/user/{user_id}", name="Update user")
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if data.password or data.password_repeat:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
)
account = Account(
id=user_id,
username=data.username,
email=data.email,
pubkey=data.pubkey,
extra=data.extra or UserExtra(),
)
await update_user_account(account)
await update_user_extensions(user_id, data.extensions or [])
return data
@users_router.delete(
"/user/{user_id}",
status_code=HTTPStatus.OK,
name="Delete user by Id",
)
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus:
) -> None:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise HTTPException(
@@ -157,13 +67,10 @@ async def api_users_delete_user(
detail="Only super_user can delete admin user.",
)
await delete_account(user_id)
return SimpleStatus(success=True, message="User deleted.")
@users_router.put(
"/user/{user_id}/reset_password",
dependencies=[Depends(check_super_user)],
name="Reset user password",
"/user/{user_id}/reset_password", dependencies=[Depends(check_super_user)]
)
async def api_users_reset_password(user_id: str) -> str:
if user_id == settings.super_user:
@@ -180,54 +87,28 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}"
@users_router.get(
"/user/{user_id}/admin",
dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user",
)
async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot change super user.",
)
if settings.is_admin_user(user_id):
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
await update_admin_settings(update_settings)
return SimpleStatus(
success=True, message=f"User admin: '{settings.is_admin_user(user_id)}'."
)
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
@users_router.get("/user/{user_id}/wallet")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
return await get_wallets(user_id)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
async def api_users_create_user_wallet(
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
):
if currency and currency not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
wallet = await create_wallet(user_id=user_id, wallet_name=name)
if currency:
wallet.currency = currency
await update_wallet(wallet)
return wallet
@users_router.put(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
@@ -242,18 +123,10 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
)
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
return SimpleStatus(success=True, message="Wallet undeleted.")
return SimpleStatus(success=True, message="Wallet is already active.")
@users_router.delete(
"/user/{user_id}/wallet/{wallet}",
name="Delete wallet by id",
summary="First time it is called it does a soft delete (only sets a flag)."
"The second time it is called will delete the entry from the DB",
)
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
@users_router.delete("/user/{user_id}/wallet/{wallet}")
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
@@ -263,20 +136,18 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatu
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
return SimpleStatus(success=True, message="Wallet deleted.")
@users_router.put(
"/topup",
name="Topup",
summary="Update balance for a particular wallet.",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(data: CreateTopup) -> SimpleStatus:
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return SimpleStatus(success=True, message="Balance updated.")
return {"status": "Success"}
+20 -42
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
import asyncio
import datetime
import json
import os
import re
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
from typing import Any, Generic, Literal, Optional, TypeVar, Union
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -51,7 +51,7 @@ def compat_timestamp_placeholder(key: str):
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
if type_ == datetime.datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
@@ -68,7 +68,7 @@ class Compat:
return f"{seconds}"
return "<nothing>"
def datetime_to_timestamp(self, date: datetime):
def datetime_to_timestamp(self, date: datetime.datetime):
if self.type in {POSTGRES, COCKROACH}:
return date.strftime("%Y-%m-%d %H:%M:%S")
elif self.type == SQLITE:
@@ -135,7 +135,7 @@ class Connection(Compat):
for key, raw_value in values.items():
if isinstance(raw_value, str):
clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime):
elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp()
if self.type == SQLITE:
clean_values[key] = int(ts)
@@ -175,9 +175,7 @@ class Connection(Compat):
return dict_to_model(row, model)
return row
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
async def update(self, table_name: str, model: BaseModel, where: str = "id = :id"):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
@@ -285,18 +283,18 @@ class Database(Compat):
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
def _parse_date(value) -> datetime.datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
return datetime.datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
encoder=datetime.datetime,
decoder=_parse_date,
schema="pg_catalog",
)
@@ -480,7 +478,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
stmt = []
for key in self.values.keys() if self.values else []:
clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
if (
self.model
and self.model.__fields__[clean_key].type_ == datetime.datetime
):
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
@@ -605,17 +606,12 @@ def model_to_dict(model: BaseModel) -> dict:
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime):
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if (
type(type_) is type(BaseModel)
or type_ is dict
or get_origin(outertype_) is list
):
if type(type_) is type(BaseModel):
_dict[key] = json.dumps(value)
continue
_dict[key] = value
@@ -631,7 +627,9 @@ def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[T
_subdict = json.loads(value)
elif isinstance(value, dict):
_subdict = value
else:
logger.warning(f"Expected str or dict, got {type(value)}")
return None
# recursively convert nested models
return dict_to_model(_subdict, model)
@@ -644,40 +642,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
_dict: dict = {}
for key, value in _row.items():
if value is None:
continue
if key not in model.__fields__:
# Somethimes an SQL JOIN will create additional column
logger.warning(f"Converting {key} to model `{model}`.")
continue
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if get_origin(outertype_) is list:
_items = json.loads(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
]
continue
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
+5 -9
View File
@@ -14,8 +14,8 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_user,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
)
from lnbits.core.models import (
@@ -26,7 +26,7 @@ from lnbits.core.models import (
User,
WalletTypeInfo,
)
from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
@@ -90,7 +90,6 @@ class KeyChecker(SecurityBase):
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -149,11 +148,10 @@ async def check_user_exists(
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user_from_account(account)
user = await get_user(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
@@ -235,9 +233,7 @@ def parse_filters(model: Type[TFilterModel]):
return dependency
async def check_user_extension_access(
user_id: str, ext_id: str, conn: Optional[Connection] = None
) -> SimpleStatus:
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
@@ -248,7 +244,7 @@ async def check_user_extension_access(
)
if settings.is_extension_id(ext_id):
ext_ids = await get_user_active_extensions_ids(user_id, conn=conn)
ext_ids = await get_user_active_extensions_ids(user_id)
if ext_id not in ext_ids:
return SimpleStatus(
success=False, message=f"User extension '{ext_id}' not enabled."
+23 -25
View File
@@ -26,31 +26,33 @@ class InvoiceError(Exception):
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if not request.headers:
return None
if "text/html" not in request.headers.get("accept", ""):
return None
if (
isinstance(exc, HTTPException)
and exc.headers
and "token-expired" in exc.headers
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie("is_access_token_expired", "true")
return response
if (
isinstance(exc, HTTPException)
and exc.headers
and "token-expired" in exc.headers
):
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie("is_access_token_expired", "true")
return response
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
return None
def register_exception_handlers(app: FastAPI):
@@ -68,8 +70,6 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"AssertionError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
@@ -78,8 +78,6 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"ValueError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
+2 -46
View File
@@ -1,17 +1,15 @@
import hashlib
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional, Type
from urllib import request
import jinja2
import jwt
import shortuuid
from packaging import version
from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
@@ -86,8 +84,6 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
settings.lnbits_extensions_deactivate_all
)
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
@@ -95,8 +91,7 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None
)
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = list(settings.lnbits_all_extensions_ids)
t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
@@ -188,16 +183,6 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None
def is_valid_pubkey(pubkey: str) -> bool:
if len(pubkey) != 64:
return False
try:
int(pubkey, 16)
return True
except Exception as _:
return False
def create_access_token(data: dict):
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.auth_token_expire_minutes
@@ -226,32 +211,3 @@ def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
# return shallow clone of the dict even if there are no filters
return {**data}
return {key: data[key] for key in filter_keys if key in data}
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
# remove release candidate suffix
v = v.split("-")[0].split("rc")[0]
return version.parse(v)
except Exception:
return version.parse("0.0.0")
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
+2 -96
View File
@@ -1,21 +1,15 @@
import asyncio
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any, List, Optional, Union
from typing import Any, List, Union
from fastapi import FastAPI, Request, Response
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
from lnbits.core.db import core_app_extra
from lnbits.core.models import AuditEntry
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -126,94 +120,6 @@ class ExtensionsRedirectMiddleware:
await self.app(scope, receive, send)
class AuditMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None:
super().__init__(app)
self.audit_queue = audit_queue
# delete_time purge after X days
# time, # include pats, exclude paths (regex)
async def dispatch(self, request: Request, call_next) -> Response:
start_time = datetime.now(timezone.utc)
request_details = await self._request_details(request)
response: Optional[Response] = None
try:
response = await call_next(request)
assert response
return response
finally:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration, request_details)
async def _log_audit(
self,
request: Request,
response: Optional[Response],
duration: float,
request_details: Optional[str],
):
try:
http_method = request.scope.get("method", None)
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
response_code = str(response.status_code) if response else None
if not settings.audit_http_request(http_method, path, response_code):
return None
ip_address = (
request.client.host
if settings.lnbits_audit_log_ip_address and request.client
else None
)
user_id = request.scope.get("user_id", None)
if settings.is_super_user(user_id):
user_id = "super_user"
component = "core"
if path and not path.startswith("/api"):
component = path.split("/")[1]
data = AuditEntry(
component=component,
ip_address=ip_address,
user_id=user_id,
path=path,
request_type=request.scope.get("type", None),
request_method=http_method,
request_details=request_details,
response_code=response_code,
duration=duration,
)
await self.audit_queue.put(data)
except Exception as ex:
logger.warning(ex)
async def _request_details(self, request: Request) -> Optional[str]:
if not settings.audit_http_request_details():
return None
try:
http_method = request.scope.get("method", None)
path = request.scope.get("path", None)
if not settings.audit_http_request(http_method, path):
return None
details: dict = {}
if settings.lnbits_audit_log_path_params:
details["path_params"] = request.path_params
if settings.lnbits_audit_log_query_params:
details["query_params"] = dict(request.query_params)
if settings.lnbits_audit_log_request_body:
_body = await request.body()
details["body"] = _body.decode("utf-8")
details_str = json.dumps(details)
# Make sure the super_user id is not leaked
return details_str.replace(settings.super_user, "super_user")
except Exception as e:
logger.warning(e)
return None
def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter()
# latest https://slowapi.readthedocs.io/en/latest/
+4 -127
View File
@@ -4,7 +4,6 @@ import importlib
import importlib.metadata
import inspect
import json
import re
from enum import Enum
from hashlib import sha256
from os import path
@@ -127,7 +126,7 @@ class InstalledExtensionsSettings(LNbitsSettings):
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_all_extensions_ids: set[str] = Field(default=[])
lnbits_all_extensions_ids: set[Any] = Field(default=[])
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
@@ -166,9 +165,6 @@ class InstalledExtensionsSettings(LNbitsSettings):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def extension_upgrade_hash(self, ext_id: str) -> str:
return settings.lnbits_upgraded_extensions.get(ext_id, "")
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
@@ -227,27 +223,14 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000)
lnbits_reserve_fee_percent: float = Field(default=1.0)
lnbits_service_fee: float = Field(default=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(self, amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = self.lnbits_reserve_fee_min
reserve_percent = self.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
class SecuritySettings(LNbitsSettings):
@@ -316,7 +299,6 @@ class LndRestFundingSource(LNbitsSettings):
lnd_rest_macaroon: Optional[str] = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True)
lnd_rest_allow_self_payment: bool = Field(default=False)
lnd_cert: Optional[str] = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None)
@@ -502,104 +484,16 @@ class KeycloakAuthSettings(LNbitsSettings):
keycloak_client_secret: str = Field(default="")
class AuditSettings(LNbitsSettings):
lnbits_audit_enabled: bool = Field(default=True)
# number of days to keep the audit entry
lnbits_audit_retention_days: int = Field(default=7)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
lnbits_audit_log_query_params: bool = Field(default=True)
lnbits_audit_log_request_body: bool = Field(default=False)
# List of paths to be included (regex match). Empty list means all.
lnbits_audit_include_paths: list[str] = Field(default=[".*api/v1/.*"])
# List of paths to be excluded (regex match). Empty list means none.
lnbits_audit_exclude_paths: list[str] = Field(default=["/static"])
# List of HTTP methods to be included. Empty lists means all.
# Options (case-sensitive): GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
lnbits_audit_http_methods: list[str] = Field(
default=["POST", "PUT", "PATCH", "DELETE"]
)
# List of HTTP codes to be included (regex match). Empty lists means all.
lnbits_audit_http_response_codes: list[str] = Field(default=["4.*", "5.*"])
def audit_http_request_details(self) -> bool:
return (
self.lnbits_audit_log_path_params
or self.lnbits_audit_log_query_params
or self.lnbits_audit_log_request_body
)
def audit_http_request(
self,
http_method: Optional[str] = None,
path: Optional[str] = None,
http_response_code: Optional[str] = None,
) -> bool:
if not self.lnbits_audit_enabled:
return False
if len(self.lnbits_audit_http_methods) != 0:
if not http_method:
return False
if http_method not in self.lnbits_audit_http_methods:
return False
if not self._is_http_request_path_auditable(path):
return False
if not self._is_http_response_code_auditable(http_response_code):
return False
return True
def _is_http_request_path_auditable(self, path: Optional[str]):
if len(self.lnbits_audit_exclude_paths) != 0 and path:
for exclude_path in self.lnbits_audit_exclude_paths:
if _re_fullmatch_safe(exclude_path, path):
return False
if len(self.lnbits_audit_include_paths) != 0:
if not path:
return False
for include_path in self.lnbits_audit_include_paths:
if _re_fullmatch_safe(include_path, path):
return True
return False
def _is_http_response_code_auditable(
self, http_response_code: Optional[str]
) -> bool:
if not http_response_code:
# No response code means only request filters should apply
return True
if len(self.lnbits_audit_http_response_codes) == 0:
return True
for response_code in self.lnbits_audit_http_response_codes:
if _re_fullmatch_safe(response_code, http_response_code):
return True
return False
class EditableSettings(
UsersSettings,
ExtensionsSettings,
ThemesSettings,
OpsSettings,
FeeSettings,
SecuritySettings,
FundingSourcesSettings,
LightningSettings,
WebPushSettings,
NodeUISettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
GoogleAuthSettings,
@@ -754,11 +648,8 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user
)
def is_super_user(self, user_id: Optional[str] = None) -> bool:
return user_id == self.super_user
def is_admin_user(self, user_id: str) -> bool:
return self.is_super_user(user_id) or user_id in self.lnbits_admin_users
return user_id in self.lnbits_admin_users or user_id == self.super_user
def is_admin_extension(self, ext_id: str) -> bool:
return ext_id in self.lnbits_admin_extensions
@@ -776,20 +667,6 @@ class AdminSettings(EditableSettings):
lnbits_allowed_funding_sources: Optional[list[str]]
class SettingsField(BaseModel):
id: str
value: Optional[Any]
tag: str = "core"
def _re_fullmatch_safe(pattern: str, string: str):
try:
return re.fullmatch(pattern, string) is not None
except Exception as _:
logger.warning(f"Regex error for pattern {pattern}")
return False
def set_cli_settings(**kwargs):
for key, value in kwargs.items():
setattr(settings, key, value)
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -526,6 +526,10 @@ video {
word-break: break-word;
}
.q-card--dark, .q-date--dark {
box-shadow: none;
}
.q-card code {
overflow-wrap: break-word;
}
+1 -5
View File
@@ -2,10 +2,8 @@ window.localisation.en = {
confirm: 'Yes',
server: 'Server',
theme: 'Theme',
site_customisation: 'Site Customisation',
funding: 'Funding',
users: 'Users',
audit: 'Audit',
apps: 'Apps',
channels: 'Channels',
transactions: 'Transactions',
@@ -123,7 +121,6 @@ window.localisation.en = {
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user',
disable: 'Disable',
delete: 'Delete',
installed: 'Installed',
activated: 'Activated',
deactivated: 'Deactivated',
@@ -271,6 +268,5 @@ window.localisation.en = {
contributors: 'Contributors',
license: 'License',
reset_key: 'Reset Key',
reset_password: 'Reset Password',
border_choices: 'Border Choices'
reset_password: 'Reset Password'
}
-25
View File
@@ -12,7 +12,6 @@ window.app = Vue.createApp({
'confettiFireworks',
'confettiStars'
],
borderOptions: ['retro-border', 'hard-border', 'no-border'],
tab: 'user',
credentialsData: {
show: false,
@@ -64,27 +63,6 @@ window.app = Vue.createApp({
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
applyBorder: function () {
slef = this
if (this.borderChoice) {
this.$q.localStorage.setItem('lnbits.border', this.borderChoice)
}
let borderStyle = this.$q.localStorage.getItem('lnbits.border')
this.borderChoice = borderStyle
let borderStyleCSS
if (borderStyle == 'hard-border') {
borderStyleCSS = `box-shadow: 0 0 0 1px rgba(0,0,0,.12), 0 0 0 1px #ffffff47; border: none;`
}
if (borderStyle == 'no-border') {
borderStyleCSS = `box-shadow: none; border: none;`
}
if (borderStyle == 'retro-border') {
borderStyleCSS = `border: none; border-color: rgba(255, 255, 255, 0.28); box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);`
}
let style = document.createElement('style')
style.innerHTML = `body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card.q-card--dark, .q-date--dark { ${borderStyleCSS} }`
document.head.appendChild(style)
},
toggleGradient: function () {
this.gradientChoice = !this.gradientChoice
this.applyGradient()
@@ -212,8 +190,5 @@ window.app = Vue.createApp({
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
if (this.$q.localStorage.getItem('lnbits.border')) {
this.applyBorder()
}
}
})
-55
View File
@@ -42,9 +42,6 @@ window.app = Vue.createApp({
formAllowedIPs: '',
formBlockedIPs: '',
nostrAcceptedUrl: '',
formAddIncludePath: '',
formAddExcludePath: '',
formAddIncludeResponseCode: '',
isSuperUser: false,
wallet: {},
cancel: {},
@@ -105,58 +102,6 @@ window.app = Vue.createApp({
let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
},
addIncludePath() {
if (!this.formAddIncludePath) {
return
}
const paths = this.formData.lnbits_audit_include_paths
if (!paths.includes(this.formAddIncludePath)) {
this.formData.lnbits_audit_include_paths = [
...paths,
this.formAddIncludePath
]
}
this.formAddIncludePath = ''
},
removeIncludePath(path) {
this.formData.lnbits_audit_include_paths =
this.formData.lnbits_audit_include_paths.filter(p => p !== path)
},
addExcludePath() {
if (!this.formAddExcludePath) {
return
}
const paths = this.formData.lnbits_audit_exclude_paths
if (!paths.includes(this.formAddExcludePath)) {
this.formData.lnbits_audit_exclude_paths = [
...paths,
this.formAddExcludePath
]
}
this.formAddExcludePath = ''
},
removeExcludePath(path) {
this.formData.lnbits_audit_exclude_paths =
this.formData.lnbits_audit_exclude_paths.filter(p => p !== path)
},
addIncludeResponseCode() {
if (!this.formAddIncludeResponseCode) {
return
}
const codes = this.formData.lnbits_audit_http_response_codes
if (!codes.includes(this.formAddIncludeResponseCode)) {
this.formData.lnbits_audit_http_response_codes = [
...codes,
this.formAddIncludeResponseCode
]
}
this.formAddIncludeResponseCode = ''
},
removeIncludeResponseCode(code) {
this.formData.lnbits_audit_http_response_codes =
this.formData.lnbits_audit_http_response_codes.filter(c => c !== code)
},
addExtensionsManifest() {
const addManifest = this.formAddExtensionsManifest.trim()
const manifests = this.formData.lnbits_extensions_manifests
-386
View File
@@ -1,386 +0,0 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
auditEntries: [],
searchData: {
user_id: '',
ip_address: '',
request_type: '',
component: '',
request_method: '',
response_code: '',
path: ''
},
searchOptions: {
component: [],
request_method: [],
response_code: []
},
auditTable: {
columns: [
{
name: 'created_at',
align: 'center',
label: 'Date',
field: 'created_at',
sortable: true
},
{
name: 'duration',
align: 'left',
label: 'Duration (sec)',
field: 'duration',
sortable: true
},
{
name: 'component',
align: 'left',
label: 'Component',
field: 'component',
sortable: false
},
{
name: 'request_method',
align: 'left',
label: 'Method',
field: 'request_method',
sortable: false
},
{
name: 'response_code',
align: 'left',
label: 'Code',
field: 'response_code',
sortable: false
},
{
name: 'user_id',
align: 'left',
label: 'User Id',
field: 'user_id',
sortable: false
},
{
name: 'ip_address',
align: 'left',
label: 'IP Address',
field: 'ip_address',
sortable: false
},
{
name: 'path',
align: 'left',
label: 'Path',
field: 'path',
sortable: false
}
],
pagination: {
sortBy: 'created_at',
rowsPerPage: 10,
page: 1,
descending: true,
rowsNumber: 10
},
search: null,
hideEmpty: true,
loading: false
},
auditDetailsDialog: {
data: null,
show: false
}
}
},
async created() {
await this.fetchAudit()
},
mounted() {
this.initCharts()
},
methods: {
async fetchAudit(props) {
try {
const params = LNbits.utils.prepareFilterQuery(this.auditTable, props)
const {data} = await LNbits.api.request(
'GET',
`/audit/api/v1?${params}`
)
this.auditTable.pagination.rowsNumber = data.total
this.auditEntries = data.data
await this.fetchAuditStats(props)
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.auditTable.loading = false
}
},
async fetchAuditStats(props) {
try {
const params = LNbits.utils.prepareFilterQuery(this.auditTable, props)
const {data} = await LNbits.api.request(
'GET',
`/audit/api/v1/stats?${params}`
)
const request_methods = data.request_method.map(rm => rm.field)
this.searchOptions.request_method = [
...new Set(this.searchOptions.request_method.concat(request_methods))
]
this.requestMethodChart.data.labels = request_methods
this.requestMethodChart.data.datasets[0].data = data.request_method.map(
rm => rm.total
)
this.requestMethodChart.update()
const response_codes = data.response_code.map(rm => rm.field)
this.searchOptions.response_code = [
...new Set(this.searchOptions.response_code.concat(response_codes))
]
this.responseCodeChart.data.labels = response_codes
this.responseCodeChart.data.datasets[0].data = data.response_code.map(
rm => rm.total
)
this.responseCodeChart.update()
const components = data.component.map(rm => rm.field)
this.searchOptions.component = [
...new Set(this.searchOptions.component.concat(components))
]
this.componentUseChart.data.labels = components
this.componentUseChart.data.datasets[0].data = data.component.map(
rm => rm.total
)
this.componentUseChart.update()
this.longDurationChart.data.labels = data.long_duration.map(
rm => rm.field
)
this.longDurationChart.data.datasets[0].data = data.long_duration.map(
rm => rm.total
)
this.longDurationChart.update()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
async searchAuditBy(fieldName, fieldValue) {
if (fieldName) {
this.searchData[fieldName] = fieldValue
}
// remove empty fields
this.auditTable.filter = Object.entries(this.searchData).reduce(
(a, [k, v]) => (v ? ((a[k] = v), a) : a),
{}
)
await this.fetchAudit()
},
showDetailsDialog(entry) {
const details = JSON.parse(entry?.request_details || '')
try {
if (details.body) {
details.body = JSON.parse(details.body)
}
} catch (e) {
// do nothing
}
this.auditDetailsDialog.data = JSON.stringify(details, null, 4)
this.auditDetailsDialog.show = true
},
formatDate: function (value) {
return Quasar.date.formatDate(new Date(value), 'YYYY-MM-DD HH:mm')
},
shortify(value) {
valueLength = (value || '').length
if (valueLength <= 10) {
return value
}
return `${value.substring(0, 5)}...${value.substring(valueLength - 5, valueLength)}`
},
initCharts() {
this.responseCodeChart = new Chart(
this.$refs.responseCodeChart.getContext('2d'),
{
type: 'doughnut',
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
},
title: {
display: false,
text: 'HTTP Response Codes'
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchAuditBy('response_code', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
label: '',
data: [20, 10],
backgroundColor: [
'rgb(100, 99, 200)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)'
]
}
],
labels: []
}
}
)
this.requestMethodChart = new Chart(
this.$refs.requestMethodChart.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchAuditBy('request_method', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)'
],
hoverOffset: 4
}
]
}
}
)
this.componentUseChart = new Chart(
this.$refs.componentUseChart.getContext('2d'),
{
type: 'pie',
options: {
responsive: true,
plugins: {
legend: {
position: 'xxx'
},
title: {
display: false,
text: 'Components'
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchAuditBy('component', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
data: [],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)',
'rgb(100, 205, 250)',
'rgb(120, 205, 250)',
'rgb(140, 205, 250)',
'rgb(160, 205, 250)'
],
hoverOffset: 4
}
]
}
}
)
this.longDurationChart = new Chart(
this.$refs.longDurationChart.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
indexAxis: 'y',
maintainAspectRatio: false,
plugins: {
legend: {
title: {
display: false,
text: 'Long Duration'
}
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchAuditBy('path', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)',
'rgb(100, 205, 250)',
'rgb(120, 205, 250)',
'rgb(140, 205, 250)',
'rgb(160, 205, 250)'
],
hoverOffset: 4
}
]
}
}
)
}
}
})
+46 -35
View File
@@ -209,7 +209,19 @@ window.LNbits = {
},
map: {
extension: function (data) {
const obj = {...data}
const obj = _.object(
[
'code',
'isValid',
'isAdminOnly',
'name',
'shortDescription',
'tile',
'contributors',
'hidden'
],
data
)
obj.url = ['/', obj.code, '/'].join('')
return obj
},
@@ -266,7 +278,7 @@ window.LNbits = {
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra ?? {},
extra: data.extra ? JSON.parse(data.extra) : {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
@@ -274,10 +286,13 @@ window.LNbits = {
fiat_currency: data.fiat_currency
}
obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm')
obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.dateFrom = moment(obj.date).fromNow()
obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry),
new Date(obj.expiry * 1000),
'YYYY-MM-DD HH:mm'
)
obj.expirydateFrom = moment(obj.expirydate).fromNow()
@@ -467,7 +482,6 @@ window.windowMixin = {
return {
toggleSubs: true,
reactionChoice: 'confettiBothSides',
borderChoice: '',
gradientChoice:
this.$q.localStorage.getItem('lnbits.gradientBg') || false,
isUserAuthorized: false,
@@ -509,30 +523,6 @@ window.windowMixin = {
document.head.appendChild(style)
}
},
applyBorder: function () {
if (this.borderChoice) {
this.$q.localStorage.setItem('lnbits.border', this.borderChoice)
}
let borderStyle = this.$q.localStorage.getItem('lnbits.border')
if (!borderStyle) {
this.$q.localStorage.set('lnbits.border', 'retro-border')
borderStyle = 'hard-border'
}
this.borderChoice = borderStyle
let borderStyleCSS
if (borderStyle == 'hard-border') {
borderStyleCSS = `box-shadow: 0 0 0 1px rgba(0,0,0,.12), 0 0 0 1px #ffffff47; border: none;`
}
if (borderStyle == 'no-border') {
borderStyleCSS = `box-shadow: none; border: none;`
}
if (borderStyle == 'retro-border') {
borderStyleCSS = `border: none; border-color: rgba(255, 255, 255, 0.28); box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);`
}
let style = document.createElement('style')
style.innerHTML = `body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card.q-card--dark, .q-date--dark { ${borderStyleCSS} }`
document.head.appendChild(style)
},
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
@@ -608,7 +598,6 @@ window.windowMixin = {
const theme = params.get('theme')
const darkMode = params.get('dark')
const gradient = params.get('gradient')
const border = params.get('border')
if (
theme &&
@@ -634,9 +623,6 @@ window.windowMixin = {
this.$q.localStorage.set('lnbits.darkMode', true)
}
}
if (border) {
this.$q.localStorage.set('lnbits.border', border)
}
// Remove processed parameters
fields.forEach(param => params.delete(param))
@@ -698,7 +684,6 @@ window.windowMixin = {
}
this.applyGradient()
this.applyBorder()
if (window.user) {
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
@@ -707,7 +692,33 @@ window.windowMixin = {
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
}
if (window.extensions) {
const extensions = Object.freeze(window.extensions)
const user = this.g.user
const extensions = Object.freeze(
window.extensions
.map(function (data) {
return window.LNbits.map.extension(data)
})
.filter(function (obj) {
return !obj.hidden
})
.filter(function (obj) {
if (window.user?.admin) return obj
return !obj.isAdminOnly
})
.map(function (obj) {
if (user) {
obj.isEnabled = user.extensions.indexOf(obj.code) !== -1
} else {
obj.isEnabled = false
}
return obj
})
.sort(function (a, b) {
const nameA = a.name.toUpperCase()
const nameB = b.name.toUpperCase()
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0
})
)
this.g.extensions = extensions
}
+21 -53
View File
@@ -67,34 +67,19 @@ window.app.component('lnbits-extension-list', {
data: function () {
return {
extensions: [],
user: null,
userExtensions: [],
searchTerm: ''
user: null
}
},
watch: {
searchTerm(term) {
this.userExtensions = this.updateUserExtensions(term)
}
},
methods: {
updateUserExtensions: function (filterBy) {
computed: {
userExtensions: function () {
if (!this.user) return []
const path = window.location.pathname
const userExtensions = this.user.extensions
var path = window.location.pathname
var userExtensions = this.user.extensions
return this.extensions
.filter(function (o) {
return userExtensions.indexOf(o.code) !== -1
})
.filter(function (o) {
if (!filterBy) return true
return (
`${o.code} ${o.name} ${o.short_description} ${o.url}`
.toLocaleLowerCase()
.indexOf(filterBy.toLocaleLowerCase()) !== -1
)
.filter(function (obj) {
return userExtensions.indexOf(obj.code) !== -1
})
.map(function (obj) {
obj.isActive = path.startsWith(obj.url)
@@ -102,30 +87,26 @@ window.app.component('lnbits-extension-list', {
})
}
},
created: async function () {
if (window.user) {
this.user = LNbits.map.user(window.user)
}
try {
const {data} = await LNbits.api.request('GET', '/api/v1/extension')
this.extensions = data
created: function () {
if (window.extensions) {
this.extensions = window.extensions
.map(function (data) {
return LNbits.map.extension(data)
})
.sort(function (a, b) {
return a.name.localeCompare(b.name)
})
this.userExtensions = this.updateUserExtensions()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
if (window.user) {
this.user = LNbits.map.user(window.user)
}
}
})
window.app.component('lnbits-manage', {
template: '#lnbits-manage',
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
methods: {
isActive: function (path) {
return window.location.pathname === path
@@ -219,25 +200,11 @@ window.app.component('lnbits-qrcode', {
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
props: ['value'],
data() {
return {
custom: {
margin: 1,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
logo: LNBITS_QR_LOGO
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
@@ -442,7 +409,7 @@ window.app.component('lnbits-dynamic-fields', {
data() {
return {
formData: null,
rules: [val => !!val || 'Field is required']
rules: [val => !!val || 'Field is required'],
}
},
methods: {
@@ -461,7 +428,7 @@ window.app.component('lnbits-dynamic-fields', {
},
handleValueChanged() {
this.$emit('update:model-value', this.formData)
}
},
},
created() {
this.formData = this.buildData(this.options, this.modelValue)
@@ -483,7 +450,7 @@ window.app.component('lnbits-dynamic-chips', {
if (!this.chip) return
this.chips.push(this.chip)
this.chip = ''
this.$emit('update:model-value', this.chips.join(','))
this.modelValue = this.chips.join(',')
},
removeChip(index) {
this.chips.splice(index, 1)
@@ -497,6 +464,7 @@ window.app.component('lnbits-dynamic-chips', {
this.chips = [...this.modelValue]
}
}
})
window.app.component('lnbits-update-balance', {
@@ -64,8 +64,7 @@ window.app.component('lnbits-funding-sources', {
lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: 'Enable Route Hints',
lnd_rest_allow_self_payment: 'Allow Self Payment'
lnd_rest_route_hints: 'Enable Route Hints'
}
],
[
+8 -6
View File
@@ -1,3 +1,9 @@
function shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
window.app.component('lnbits-node-ranks', {
props: ['ranks'],
data: function () {
@@ -135,11 +141,7 @@ window.app.component('lnbits-node-info', {
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
shortenNodeId
},
template: `
<div class='row items-baseline q-gutter-x-sm'>
@@ -248,7 +250,7 @@ window.app.component('lnbits-date', {
props: ['ts'],
computed: {
date: function () {
return Quasar.date.formatDate(
return Quasar.utils.date.formatDate(
new Date(this.ts * 1000),
'YYYY-MM-DD HH:mm'
)
+177 -204
View File
@@ -3,35 +3,60 @@ window.app = Vue.createApp({
mixins: [window.windowMixin],
data: function () {
return {
paymentsWallet: {},
isSuperUser: false,
activeWallet: {},
wallet: {},
cancel: {},
users: [],
wallets: [],
searchData: {
user: '',
username: '',
email: '',
pubkey: ''
},
paymentPage: {
paymentDialog: {
show: false
},
activeWallet: {
userId: null,
walletDialog: {
show: false
},
topupDialog: {
show: false
},
activeUser: {
data: null,
showUserId: false,
createUserDialog: {
data: {},
fields: [
{
description: 'Username',
name: 'username'
},
{
description: 'Email',
name: 'email'
},
{
type: 'password',
description: 'Password',
name: 'password'
}
],
show: false
},
createWalletDialog: {
data: {},
fields: [
{
type: 'str',
description: 'Wallet Name',
name: 'name'
},
{
type: 'select',
values: ['', 'EUR', 'USD'],
description: 'Currency',
name: 'currency'
},
{
type: 'str',
description: 'Balance',
name: 'balance'
}
],
show: false
},
walletTable: {
@@ -42,12 +67,6 @@ window.app = Vue.createApp({
label: 'Name',
field: 'name'
},
{
name: 'id',
align: 'left',
label: 'Wallet Id',
field: 'id'
},
{
name: 'currency',
align: 'left',
@@ -59,65 +78,17 @@ window.app = Vue.createApp({
align: 'left',
label: 'Balance',
field: 'balance_msat'
},
{
name: 'deleted',
align: 'left',
label: 'Deleted',
field: 'deleted'
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 10,
page: 1,
descending: true,
rowsNumber: 10
},
search: null,
hideEmpty: true,
loading: false
]
},
usersTable: {
columns: [
{
name: 'admin',
align: 'left',
label: 'Admin',
field: 'admin',
sortable: false
},
{
name: 'wallet_id',
align: 'left',
label: 'Wallets',
field: 'wallet_id',
sortable: false
},
{
name: 'user',
align: 'left',
label: 'User Id',
field: 'user',
sortable: false
},
{
name: 'username',
align: 'left',
label: 'Username',
field: 'username',
sortable: false
},
{
name: 'email',
align: 'left',
label: 'Email',
field: 'email',
sortable: false
},
{
name: 'pubkey',
align: 'left',
label: 'Public Key',
field: 'pubkey',
sortable: false
},
{
name: 'balance_msat',
align: 'left',
@@ -125,15 +96,34 @@ window.app = Vue.createApp({
field: 'balance_msat',
sortable: true
},
{
name: 'wallet_count',
align: 'left',
label: 'Wallet Count',
field: 'wallet_count',
sortable: true
},
{
name: 'transaction_count',
align: 'left',
label: 'Payments',
label: 'Transaction Count',
field: 'transaction_count',
sortable: true
},
{
name: 'username',
align: 'left',
label: 'Username',
field: 'username',
sortable: true
},
{
name: 'email',
align: 'left',
label: 'Email',
field: 'email',
sortable: true
},
{
name: 'last_payment',
align: 'left',
@@ -170,7 +160,50 @@ window.app = Vue.createApp({
created() {
this.fetchUsers()
},
mounted() {
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
type: 'bubble',
options: {
scales: {
x: {
type: 'linear',
beginAtZero: true,
title: {
text: 'Transaction count'
}
},
y: {
type: 'linear',
beginAtZero: true,
title: {
text: 'User balance in million sats'
}
}
},
tooltip: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
const dataPoint = dataset.data[tooltipItem.index]
return dataPoint.customLabel || ''
}
}
},
layout: {
padding: 10
}
},
data: {
datasets: [
{
label: 'Wallet balance vs transaction count',
backgroundColor: 'rgb(255, 99, 132)',
data: []
}
]
}
})
},
methods: {
formatDate: function (value) {
return LNbits.utils.formatDate(value)
@@ -178,24 +211,17 @@ window.app = Vue.createApp({
formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000))
},
backToUsersPage() {
this.activeUser.show = false
this.paymentPage.show = false
this.activeWallet.show = false
this.fetchUsers()
},
resetPassword(user_id) {
return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
.then(res => {
LNbits.utils
.confirmDialog(
'A reset key has been generated. Click OK to copy the rest key to your clipboard.'
)
.onOk(() => {
const url = window.location.origin + '?reset_key=' + res.data
this.copyText(url)
})
this.$q.notify({
type: 'positive',
message: 'generated key for password reset',
icon: null
})
const url = window.location.origin + '?reset_key=' + res.data
this.copyText(url)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@@ -203,66 +229,33 @@ window.app = Vue.createApp({
},
createUser() {
LNbits.api
.request('POST', '/users/api/v1/user', null, this.activeUser.data)
.then(resp => {
Quasar.Notify.create({
type: 'positive',
message: 'User created!',
icon: null
})
this.activeUser.setPassword = true
this.activeUser.data = resp.data
this.fetchUsers()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateUser() {
LNbits.api
.request(
'PUT',
`/users/api/v1/user/${this.activeUser.data.id}`,
null,
this.activeUser.data
)
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
.then(() => {
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: 'User updated!',
message: 'Success! User created!',
icon: null
})
this.activeUser.data = null
this.activeUser.show = false
this.fetchUsers()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createWallet() {
const userId = this.activeWallet.userId
if (!userId) {
Quasar.Notify.create({
type: 'warning',
message: 'No user selected!',
icon: null
})
return
}
createWallet(user_id) {
LNbits.api
.request(
'POST',
`/users/api/v1/user/${userId}/wallet`,
`/users/api/v1/user/${user_id}/wallet`,
null,
this.createWalletDialog.data
)
.then(() => {
this.fetchWallets(userId)
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: 'Wallet created!'
message: 'Success! User created!',
icon: null
})
})
.catch(function (error) {
@@ -279,11 +272,9 @@ window.app = Vue.createApp({
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: 'User deleted!',
message: 'Success! User deleted!',
icon: null
})
this.activeUser.data = null
this.activeUser.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@@ -293,14 +284,14 @@ window.app = Vue.createApp({
undeleteUserWallet(user_id, wallet) {
LNbits.api
.request(
'PUT',
'GET',
`/users/api/v1/user/${user_id}/wallet/${wallet}/undelete`
)
.then(() => {
this.fetchWallets(user_id)
Quasar.Notify.create({
type: 'positive',
message: 'Undeleted user wallet!',
message: 'Success! Undeleted user wallet!',
icon: null
})
})
@@ -319,7 +310,7 @@ window.app = Vue.createApp({
this.fetchWallets(user_id)
Quasar.Notify.create({
type: 'positive',
message: 'User wallet deleted!',
message: 'Success! User wallet deleted!',
icon: null
})
})
@@ -328,11 +319,38 @@ window.app = Vue.createApp({
})
})
},
copyWalletLink(walletId) {
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
this.copyText(url)
},
updateChart(users) {
const filtered = users.filter(user => {
if (
user.balance_msat === null ||
user.balance_msat === 0 ||
user.wallet_count === 0
) {
return false
}
return true
})
const data = filtered.map(user => {
const labelUsername = `${user.username ? 'User: ' + user.username + '. ' : ''}`
const userBalanceSats = Math.floor(
user.balance_msat / 1000
).toLocaleString()
return {
x: user.transaction_count,
y: user.balance_msat / 1000000000,
r: 4,
customLabel:
labelUsername +
'Balance: ' +
userBalanceSats +
' sats. Tx count: ' +
user.transaction_count
}
})
this.chart1.data.datasets[0].data = data
this.chart1.update()
},
fetchUsers(props) {
const params = LNbits.utils.prepareFilterQuery(this.usersTable, props)
LNbits.api
@@ -341,32 +359,38 @@ window.app = Vue.createApp({
this.usersTable.loading = false
this.usersTable.pagination.rowsNumber = res.data.total
this.users = res.data.data
this.updateChart(this.users)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
fetchWallets(userId) {
fetchWallets(user_id) {
LNbits.api
.request('GET', `/users/api/v1/user/${userId}/wallet`)
.request('GET', `/users/api/v1/user/${user_id}/wallet`)
.then(res => {
this.wallets = res.data
this.activeWallet.userId = userId
this.activeWallet.show = true
this.walletDialog.show = this.wallets.length > 0
if (!this.walletDialog.show) {
this.fetchUsers()
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
toggleAdmin(userId) {
showPayments(wallet_id) {
this.activeWallet = this.wallets.find(wallet => wallet.id === wallet_id)
this.paymentDialog.show = true
},
toggleAdmin(user_id) {
LNbits.api
.request('GET', `/users/api/v1/user/${userId}/admin`)
.request('GET', `/users/api/v1/user/${user_id}/admin`)
.then(() => {
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: 'Toggled admin!',
message: 'Success! Toggled admin!',
icon: null
})
})
@@ -377,40 +401,6 @@ window.app = Vue.createApp({
exportUsers() {
console.log('export users')
},
async showAccountPage(user_id) {
this.activeUser.showPassword = false
this.activeUser.showUserId = false
this.activeUser.setPassword = false
if (!user_id) {
this.activeUser.data = {extra: {}}
this.activeUser.show = true
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/users/api/v1/user/${user_id}`
)
this.activeUser.data = data
this.activeUser.show = true
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'warning',
message: 'Failed to get user!'
})
this.activeUser.show = false
}
},
showTopupDialog(walletId) {
this.wallet.id = walletId
this.topupDialog.show = true
},
showPayments(wallet_id) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id)
this.paymentPage.show = true
},
topupCallback(res) {
if (res.success) {
this.wallets.forEach(wallet => {
@@ -432,31 +422,14 @@ window.app = Vue.createApp({
.then(_ => {
Quasar.Notify.create({
type: 'positive',
message: `Added ${this.wallet.amount} to ${this.wallet.id}`,
message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`,
icon: null
})
this.wallet = {}
this.fetchWallets(this.activeWallet.userId)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
searchUserBy(fieldName) {
const fieldValue = this.searchData[fieldName]
this.usersTable.filter = {}
if (fieldValue) {
this.usersTable.filter[fieldName] = fieldValue
}
this.fetchUsers()
},
shortify(value) {
valueLength = (value || '').length
if (valueLength <= 10) {
return value
}
return `${value.substring(0, 5)}...${value.substring(valueLength - 5, valueLength)}`
}
}
})
+11 -135
View File
@@ -8,13 +8,11 @@ window.app = Vue.createApp({
wallet: LNbits.map.wallet(window.wallet),
user: LNbits.map.user(window.user),
exportUrl: `${window.location.origin}/wallet?usr=${window.user.id}&wal=${window.wallet.id}`,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
amountMsat: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
@@ -57,9 +55,7 @@ window.app = Vue.createApp({
currency: null
},
inkeyHidden: true,
adminkeyHidden: true,
hasNfc: false,
nfcReaderAbortController: null
adminkeyHidden: true
}
},
computed: {
@@ -81,19 +77,6 @@ window.app = Vue.createApp({
canPay: function () {
if (!this.parse.invoice) return false
return this.parse.invoice.sat <= this.balance
},
formattedAmount: function () {
if (this.receive.unit != 'sat') {
return LNbits.utils.formatCurrency(
Number(this.receive.data.amount).toFixed(2),
this.receive.unit
)
} else {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat'
}
},
formattedSatAmount: function () {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat'
}
},
methods: {
@@ -121,11 +104,6 @@ window.app = Vue.createApp({
this.receive.lnurl = null
this.focusInput('setAmount')
},
onReceiveDialogHide: function () {
if (this.hasNfc) {
this.nfcReaderAbortController.abort()
}
},
showParseDialog: function () {
this.parse.show = true
this.parse.invoice = null
@@ -166,14 +144,9 @@ window.app = Vue.createApp({
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.bolt11
this.receive.amountMsat = response.data.amount
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
this.readNfcTag()
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
@@ -284,7 +257,7 @@ window.app = Vue.createApp({
})
},
decodeQR: function (res) {
this.parse.data.request = res[0].rawValue
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
@@ -420,13 +393,12 @@ window.app = Vue.createApp({
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -435,7 +407,7 @@ window.app = Vue.createApp({
break
case 'message':
Quasar.Notify.create({
message: extra.success_action.message,
message: response.data.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
@@ -446,14 +418,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
response.data.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -571,102 +543,6 @@ window.app = Vue.createApp({
navigator.clipboard.readText().then(text => {
this.parse.data.request = text.trim()
})
},
readNfcTag: function () {
try {
if (typeof NDEFReader == 'undefined') {
console.debug('NFC not supported on this device or browser.')
return
}
const ndef = new NDEFReader()
this.nfcReaderAbortController = new AbortController()
this.nfcReaderAbortController.signal.onabort = event => {
console.debug('All NFC Read operations have been aborted.')
}
this.hasNfc = true
let dismissNfcTapMsg = Quasar.Notify.create({
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
})
return ndef
.scan({signal: this.nfcReaderAbortController.signal})
.then(() => {
ndef.onreadingerror = () => {
Quasar.Notify.create({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
}
ndef.onreading = ({message}) => {
//Decode NDEF data from tag
const textDecoder = new TextDecoder('utf-8')
const record = message.records.find(el => {
const payload = textDecoder.decode(el.data)
return payload.toUpperCase().indexOf('LNURLW') !== -1
})
if (record) {
dismissNfcTapMsg()
Quasar.Notify.create({
type: 'positive',
message: 'NFC tag read successfully.'
})
const lnurl = textDecoder.decode(record.data)
this.payInvoiceWithNfc(lnurl)
} else {
Quasar.Notify.create({
type: 'warning',
message: 'NFC tag does not have LNURLw record.'
})
}
}
})
} catch (error) {
Quasar.Notify.create({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
payInvoiceWithNfc: function (lnurl) {
let dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
spinner: true,
message: this.$t('processing_payment')
})
LNbits.api
.request(
'POST',
`/api/v1/payments/${this.receive.paymentReq}/pay-with-nfc`,
this.g.wallet.adminkey,
{lnurl_w: lnurl}
)
.then(response => {
dismissPaymentMsg()
if (response.data.success) {
Quasar.Notify.create({
type: 'positive',
message: 'Payment successful'
})
} else {
Quasar.Notify.create({
type: 'negative',
message: response.data.detail || 'Payment failed'
})
}
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
}
},
created: function () {
@@ -700,7 +576,7 @@ window.app = Vue.createApp({
LNbits.events.onInvoicePaid(this.g.wallet, payment => {
this.onPaymentReceived(payment.payment_hash)
})
eventReactionWebocket(wallet.inkey)
eventReactionWebocket(wallet.id)
}
})
File diff suppressed because one or more lines are too long
+20 -12
View File
@@ -20,7 +20,8 @@ from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment,
update_payment_details,
update_payment_status,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
@@ -84,7 +85,7 @@ async def catch_everything_and_restart(
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return await catch_everything_and_restart(func, name)
return catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -180,14 +181,17 @@ async def check_pending_payments():
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
@@ -207,10 +211,14 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
status = await payment.check_status()
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
-1
View File
@@ -163,7 +163,6 @@
<lnbits-manage
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>
+269 -301
View File
@@ -96,21 +96,11 @@
<template id="lnbits-extension-list">
<q-list
v-if="user && (userExtensions.length > 0 || !!searchTerm)"
v-if="user && userExtensions.length > 0"
dense
class="lnbits-drawer__q-list"
>
<q-item>
<q-item-section>
<q-input
v-model="searchTerm"
dense
borderless
:label="$t('extensions')"
>
</q-input>
</q-item-section>
</q-item>
<q-item-label header v-text="$t('extensions')"></q-item-label>
<q-item
v-for="extension in userExtensions"
:key="extension.code"
@@ -195,24 +185,6 @@
<q-item-label lines="1" v-text="$t('users')"></q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="showAudit"
clickable
tag="a"
href="/audit"
:active="isActive('/audit')"
>
<q-item-section side>
<q-icon
name="playlist_add_check_circle"
:color="isActive('/audit') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('audit')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item
v-if="showExtensions"
@@ -279,17 +251,6 @@
/>
</div>
<div class="text-wrap">
<b style="white-space: nowrap" v-text="$t('Invoice')"></b>:&nbsp;
<q-icon
name="content_copy"
@click="copyText(payment.bolt11)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div class="text-wrap">
<b style="white-space: nowrap" v-text="$t('memo')"></b>:&nbsp;
<span v-text="payment.memo"></span>
@@ -432,7 +393,7 @@
></q-input>
<div v-else-if="o.type === 'chips'">
<lnbits-dynamic-chips
v-model="formData[o.name]"
:model-value="formData[o.name]"
@update:model-value="handleValueChanged"
></lnbits-dynamic-chips>
</div>
@@ -528,20 +489,8 @@
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<qrcode-vue
:value="value"
level="Q"
render-as="svg"
:margin="custom.margin"
:size="custom.width"
class="rounded-borders"
></qrcode-vue>
<img
v-if="custom.logo"
class="qrcode__image"
:src="custom.logo"
alt="qrcode icon"
/>
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
</div>
</template>
@@ -568,266 +517,285 @@
</template>
<template id="payment-list">
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none" :v-text="$t('transactions')"></h5>
</div>
<div class="gt-sm col-auto">
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn @click="addFilterTag" dense flat icon="add"></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-btn
v-close-popup
outline
color="grey"
@click="exportCSV(true)"
label="Export to CSV with details"
></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
<q-input
<q-card
:style="
$q.screen.lt.md
? {
display: mobileSimple ? 'none !important' : ''
background: $q.screen.lt.md ? 'none !important' : '',
boxShadow: $q.screen.lt.md ? 'none !important' : '',
marginTop: $q.screen.lt.md ? '0px !important' : ''
}
: ''
"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
v-model:pagination="paymentsTable.pagination"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip><span>failed</span></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
@click="props.expand = !props.expand"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-tooltip><span v-text="$t('pending')"></span></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
v-text="'#' + props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn @click="addFilterTag" dense flat icon="add"></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip><span v-text="props.row.date"></span></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="denomination != 'sats'"
:props="props"
class="col1"
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
>
</q-td>
<q-td class="col2" auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.wallet_fiat_amount,
props.row.extra.wallet_fiat_currency
)
"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.fiat_amount,
props.row.extra.fiat_currency
)
"
></span>
</i>
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-item>
<q-item-section>
<q-btn
v-close-popup
flat
outline
color="grey"
class="q-ml-auto"
:label="$t('close')"
@click="exportCSV(true)"
label="Export to CSV with details"
></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
<q-input
:style="
$q.screen.lt.md
? {
display: mobileSimple ? 'none !important' : ''
}
: ''
"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
v-model:pagination="paymentsTable.pagination"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip><span>failed</span></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip><span v-text="$t('pending')"></span></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
v-text="'#' + props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip><span v-text="props.row.date"></span></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="denomination != 'sats'"
:props="props"
class="col1"
v-text="
parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100
"
>
</q-td>
<q-td class="col2" auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.wallet_fiat_amount,
props.row.extra.wallet_fiat_currency
)
"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="
formatCurrency(
props.row.extra.fiat_amount,
props.row.extra.fiat_currency
)
"
></span>
</i>
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</template>
<template id="lnbits-extension-rating">
+9 -17
View File
@@ -34,26 +34,18 @@
</head>
<body>
<div id="vue">
<q-layout view="hHh lpR lfr" v-cloak>
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}{% endblock %}
</q-page>
</q-page-container>
</q-layout>
</div>
<q-layout id="vue" view="hHh lpR lfr" v-cloak>
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}{% endblock %}
</q-page>
</q-page-container>
</q-layout>
{% include('components.vue') %}{% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
{% for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
<script>
const LNBITS_QR_LOGO = {{ LNBITS_QR_LOGO | tojson }}
</script>
<!---->
{% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
{% block scripts %}{% endblock %}
</body>
</html>
+11 -13
View File
@@ -30,19 +30,17 @@ from .base import (
class FakeWallet(Wallet):
def __init__(self) -> None:
self.queue: asyncio.Queue = asyncio.Queue(0)
self.payment_secrets: Dict[str, str] = {}
self.paid_invoices: Set[str] = set()
self.secret: str = settings.fake_wallet_secret
self.privkey: str = hashlib.pbkdf2_hmac(
"sha256",
self.secret.encode(),
b"FakeWallet",
2048,
32,
).hex()
queue: asyncio.Queue = asyncio.Queue(0)
payment_secrets: Dict[str, str] = {}
paid_invoices: Set[str] = set()
secret: str = settings.fake_wallet_secret
privkey: str = hashlib.pbkdf2_hmac(
"sha256",
secret.encode(),
b"FakeWallet",
2048,
32,
).hex()
async def cleanup(self):
pass
+4 -2
View File
@@ -85,13 +85,15 @@ class LNbitsWallet(Wallet):
r.raise_for_status()
data = r.json()
if r.is_error or "bolt11" not in data:
if r.is_error or "payment_request" not in data:
error_message = data["detail"] if "detail" in data else r.text
return InvoiceResponse(
False, None, None, f"Server error: '{error_message}'"
)
return InvoiceResponse(True, data["checking_id"], data["bolt11"], None)
return InvoiceResponse(
True, data["checking_id"], data["payment_request"], None
)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
+2 -8
View File
@@ -2,7 +2,7 @@ import asyncio
import base64
import hashlib
import json
from typing import Any, AsyncGenerator, Dict, Optional
from typing import AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
@@ -168,15 +168,9 @@ class LndRestWallet(Wallet):
lnrpc_fee_limit["fixed_msat"] = f"{fee_limit_msat}"
try:
json_: dict[str, Any] = {
"payment_request": bolt11,
"fee_limit": lnrpc_fee_limit,
}
if settings.lnd_rest_allow_self_payment:
json_["allow_self_payment"] = 1
r = await self.client.post(
url="/v1/channels/transactions",
json=json_,
json={"payment_request": bolt11, "fee_limit": lnrpc_fee_limit},
timeout=None,
)
r.raise_for_status()
+1 -11
View File
@@ -36,18 +36,8 @@ class PhoenixdWallet(Wallet):
)
self.endpoint = self.normalize_endpoint(settings.phoenixd_api_endpoint)
parsed_url = urllib.parse.urlparse(settings.phoenixd_api_endpoint)
if parsed_url.scheme == "http":
ws_protocol = "ws"
elif parsed_url.scheme == "https":
ws_protocol = "wss"
else:
raise ValueError(f"Unsupported scheme: {parsed_url.scheme}")
self.ws_url = (
f"{ws_protocol}://{urllib.parse.urlsplit(self.endpoint).netloc}/websocket"
)
self.ws_url = f"ws://{urllib.parse.urlsplit(self.endpoint).netloc}/websocket"
password = settings.phoenixd_api_password
encoded_auth = base64.b64encode(f":{password}".encode())
auth = str(encoded_auth, "utf-8")
+3 -4
View File
@@ -1320,10 +1320,9 @@
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.5.11",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.11.tgz",
"integrity": "sha512-Ec/bVML1jgxSX+usbgdcXGhOFEFo4EzApCO2CNT1YK0Dcb0Mp7ASygz78RJJs22SU2oI7vz9iJDyr4ucSDTvjQ==",
"license": "MIT",
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.10.tgz",
"integrity": "sha512-lj83FKqRyvo0VLMu49wrLsaHueonfXcwyX9r/GDw0y+myOY5xTfsl75hjBgmmByAxzFSlCPI+CGA9FxYVtRAFQ==",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
Generated
+8 -8
View File
@@ -997,18 +997,18 @@ test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.115.2"
version = "0.113.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.115.2-py3-none-any.whl", hash = "sha256:61704c71286579cc5a598763905928f24ee98bfcc07aabe84cfefb98812bbc86"},
{file = "fastapi-0.115.2.tar.gz", hash = "sha256:3995739e0b09fa12f984bce8fa9ae197b35d433750d3d312422d846e283697ee"},
{file = "fastapi-0.113.0-py3-none-any.whl", hash = "sha256:c8d364485b6361fb643d53920a18d58a696e189abcb901ec03b487e35774c476"},
{file = "fastapi-0.113.0.tar.gz", hash = "sha256:b7cf9684dc154dfc93f8b718e5850577b529889096518df44defa41e73caf50f"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.37.2,<0.41.0"
starlette = ">=0.37.2,<0.39.0"
typing-extensions = ">=4.8.0"
[package.extras]
@@ -2730,13 +2730,13 @@ uvicorn = "*"
[[package]]
name = "starlette"
version = "0.40.0"
version = "0.37.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4"},
{file = "starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35"},
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
]
[package.dependencies]
@@ -3193,4 +3193,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12 | ^3.11 | ^3.10 | ^3.9"
content-hash = "f4847cf020ad21818fdad153d1845ed9c24d41d631aed1f6eec16158f6f295fb"
content-hash = "f9f70d510dbe30a94f68fc12fcf1d11c6fb754367e9f36e6167be2ca19e28495"

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