Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d316381e | ||
|
|
0e9b1b44b2 | ||
|
|
9fa0ef97c1 | ||
|
|
2e8128b85d | ||
|
|
fd0fa75e11 | ||
|
|
ff12a76b42 | ||
|
|
8a023471a9 | ||
|
|
0b7c33c060 | ||
|
|
4d2438cf49 | ||
|
|
f590efd719 | ||
|
|
d5ea1af518 | ||
|
|
f227be2ea5 | ||
|
|
3ccdc3e249 | ||
|
|
7ca0a35b3a | ||
|
|
0286906bd9 | ||
|
|
63b499d2c9 | ||
|
|
620325b399 | ||
|
|
3cfbae3b28 | ||
|
|
a94f42368d | ||
|
|
74b24eb609 | ||
|
|
c6ec329328 | ||
|
|
cd549315f1 | ||
|
|
28897ca120 | ||
|
|
e8d10abee3 | ||
|
|
7715e9c343 | ||
|
|
de1a780f6d | ||
|
|
e2eb07285f | ||
|
|
343e7847df | ||
|
|
5cb361fe43 | ||
|
|
4906bdcc4e | ||
|
|
8725e1a2b7 | ||
|
|
b0e2861aef | ||
|
|
388f9946e3 | ||
|
|
ed81ca55d9 | ||
|
|
e217e58bb6 | ||
|
|
7843a00edf | ||
|
|
7f3abcbd07 | ||
|
|
f54c78a58d | ||
|
|
f923ecdefd | ||
|
|
cf03d94d4d | ||
|
|
5f0068bc6d | ||
|
|
f85fb96063 | ||
|
|
84d9551706 | ||
|
|
9ae475d4a0 | ||
|
|
69e67d7d64 | ||
|
|
382154b04a | ||
|
|
f06b29dd82 | ||
|
|
6e1b746056 | ||
|
|
0b9e328363 | ||
|
|
f341537de2 | ||
|
|
3551ee14a4 | ||
|
|
0779535a9c | ||
|
|
b0701fdb65 | ||
|
|
955a345900 | ||
|
|
aa236d99ce | ||
|
|
5acddb75f5 | ||
|
|
2a17d5a8ce | ||
|
|
4d75545994 | ||
|
|
cc8776ac74 | ||
|
|
933c494836 | ||
|
|
ba843b3df4 | ||
|
|
03b1a4f729 | ||
|
|
0c456941e5 | ||
|
|
200f5c52a7 | ||
|
|
383f6ec721 | ||
|
|
2410c271a6 | ||
|
|
85b41c4aab | ||
|
|
cfdd81d37e | ||
|
|
c0db3468af | ||
|
|
e1fb82cea5 | ||
|
|
06bdd61c8e | ||
|
|
d813544bf4 | ||
|
|
cae81b61a3 | ||
|
|
219e8746c5 |
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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] = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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": <int>, "memo": <string>,
|
||||
@@ -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": <int>, "memo": <string>}' -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": <int>, "memo": <string>}' -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": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
@@ -169,10 +153,10 @@
|
||||
<code>{"payment_hash": <string>}</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": <string>}' -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": <string>}' -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": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
@@ -225,10 +209,9 @@
|
||||
<code>{"paid": <bool>}</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/<payment_hash> -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/<payment_hash> -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
+42
-79
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+20
-12
@@ -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():
|
||||
|
||||
@@ -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
@@ -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>:
|
||||
<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>:
|
||||
<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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Generated
+3
-4
@@ -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
@@ -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
Reference in New Issue
Block a user