Compare commits

..
Author SHA1 Message Date
dni ⚡andGitHub 6df3933c1b ci: trigger release on rc's > 9 (#3088) 2025-04-03 13:12:19 +02:00
dni ⚡andGitHub ffef085fa7 chore: update to v1.0.0-rc10 (#3087) 2025-04-03 12:55:30 +02:00
tlindiandGitHub 913991c72f Update fi.js-1.0.0-rc10-tobe (#3086) 2025-04-03 10:31:49 +03:00
dni ⚡andGitHub 6f611461f2 fix: FakeWallet, bolt11 payment_secret is not the preimage (#3084) 2025-04-02 18:46:24 +03:00
Tiago VasconcelosandGitHub 681385e2a2 fix dialog consistency (#3082) 2025-04-02 16:03:40 +02:00
dni ⚡andGitHub 883d52c303 fix: phoenixd uses sat instead of msat for pay_invoice (#3083) 2025-04-02 16:49:59 +03:00
Vlad StanandGitHub 1058461882 fix: include node balance in server status (#3079) 2025-04-02 15:31:47 +03:00
Vlad StanandGitHub 3a7a88857a fix: missing label (#3081) 2025-04-02 14:51:15 +03:00
Vlad StanandGitHub 6834b5e00f feat: do not allow regular admins to edit super user (#3077) 2025-04-01 14:01:02 +03:00
Vlad StanandGitHub 10093bb465 feat: show wallet payments on search (#3076) 2025-04-01 12:41:10 +02:00
dni ⚡andGitHub 1323a2005b feat: cleanup on library dir creation and upload endpoints (#3069) 2025-04-01 10:27:11 +03:00
Vlad StanandGitHub bafb4ddf75 fix: allow admin to view payments in deleted wallets (#3074) 2025-03-31 17:18:32 +03:00
Vlad StanandGitHub aa050eaf49 fix: make parallel calls for exchages (#3073) 2025-03-31 14:44:36 +03:00
ArcandGitHub 0c76efa2b9 Add ws link to curl examples (#3063) 2025-03-31 12:58:26 +03:00
PatMulliganandGitHub 681730a4af [CHORE] add responsive text scaling with clamp() for wallet balance display (#3071) 2025-03-31 11:59:58 +03:00
ArcandGitHub 63adcb6780 Adds image library for admins (#3066) 2025-03-27 11:16:20 +02:00
dni ⚡andGitHub 35f7821183 fix: make swagger understand the access token HTTPBearer (#3068) 2025-03-27 09:14:42 +00:00
Vlad StanandGitHub b185d9585c fix: add default extensions when a user is created (#3061) 2025-03-22 23:54:15 +00:00
ArcandGitHub 30a8d88ada Remove LNbits UI elements (#3064) 2025-03-22 21:08:26 +00:00
ArcandGitHub f7e984198b Revert "tweak: fix for ws payment" (#3060) 2025-03-21 14:03:52 +02:00
ArcandGitHub eb09e95e88 tweak: fix for ws payment (#3057) 2025-03-20 23:51:10 +00:00
Tiago VasconcelosandGitHub a883e2c7d8 hide 'Runs On' scroll if title is not LNbits (#3053) 2025-03-18 10:19:10 +01:00
Tiago VasconcelosandGitHub e06abb52ce [Fix] default to sats if wallet not fakewallet (#3055) 2025-03-18 10:17:47 +01:00
tlindiandGitHub 9f64b5349d Dev update fi.js again (#3052) 2025-03-18 11:07:37 +02:00
ArcandGitHub a6bbddce41 tweak: use image that works for fav and qr as default (#3051) 2025-03-14 10:48:58 +01:00
Tiago VasconcelosandGitHub adb9d24673 [Fix] Set default chart text color (#3048)
* fix: set default chart text
white when darkMode else black
2025-03-12 17:36:11 +01:00
Tiago VasconcelosandGitHub dc85f26964 fix: qr scanner button was missing the method (#3046) 2025-03-12 16:09:53 +01:00
Tiago VasconcelosandGitHub 14153b4a8f [Fix] Remove references and warnings for BETA (#3047)
* fix: remove BETA references/warnings
2025-03-12 16:09:18 +01:00
Vlad StanandGitHub b8a5f3942c [fix] limit the size of the invoice description to 640 characters (#3045) 2025-03-12 16:29:29 +02:00
dni ⚡andGitHub 475ae5736e chore: update to v1.0.0-rc9 (#3043) 2025-03-12 12:29:01 +01:00
PatMulliganandGitHub d7f0f4da0c create backwards compatibility for <=v1.0 (#3041)
in v0.12.12, the key is "payment_request" instead of the new "bolt11"
2025-03-12 12:27:24 +01:00
Tiago VasconcelosandGitHub f7b3444be4 fix: unfuck the swap mess (#3023) 2025-03-12 12:14:20 +01:00
ArcandGitHub 28e32a54a4 tweak: use qr code image for favicon as well (#3031)
use qr code image for favicon as well
2025-03-12 12:12:43 +01:00
ArcandGitHub 1833adc0a1 Tweak: moved custom denomination to fakewallet funding source (#3030) 2025-03-12 12:10:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
b759ec7468 chore(deps): bump axios from 1.7.7 to 1.8.2 (#3042)
* chore(deps): bump axios from 1.7.7 to 1.8.2

Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:10:27 +01:00
2661d12ff1 tweak: fixes service fee badge (#3029)
* tweak: fixes service fee badge

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:08:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
ed419f27ac chore(deps): bump vue-i18n from 10.0.5 to 10.0.6 (#3022)
* chore(deps): bump vue-i18n from 10.0.5 to 10.0.6

Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.5 to 10.0.6.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.6/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:01:18 +01:00
dni ⚡andGitHub c3efd48108 chore: use latest 1.X poetry version (#3039) 2025-03-11 09:54:36 +01:00
ArcandGitHub 4a28c22d79 tweak: fix 'test email' btn (#3026) 2025-03-10 18:37:11 +00:00
ArcandGitHub 0d0eb36de4 tweak: fix funding source hide (#3027)
added to title rather than all the inputs
2025-03-10 18:36:48 +00:00
ArcandGitHub 2aa73bfe75 tweak: punchy bitcoin theme (#3028) 2025-03-10 18:36:23 +00:00
Vlad StanandGitHub ebd080192c feat: return payment status in the websocket response (#3025) 2025-03-10 12:48:40 +02:00
Tiago VasconcelosandGitHub 41abe63f18 hide text on small screen (#3021) 2025-03-07 14:23:46 +00:00
dni ⚡andGitHub f61471b0f2 feat: add test email and improve on email notifications (#3018) 2025-03-05 09:53:40 +02:00
Vlad StanandGitHub 9bd037b6e7 [fix] settings cleanup (#3013) 2025-03-04 16:30:54 +02:00
a4ca88b6a4 tweak: dont show full id on wallet select box (#3017)
* tweak: dont show full id on wallet select box
* chore bundle

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-04 11:23:02 +01:00
ArcandGitHub f2b9aafc51 Tweak: Changed naming for password area (#3016) 2025-03-04 11:13:41 +01:00
57 changed files with 962 additions and 907 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ inputs:
default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
default: "1.8.5"
node-version:
description: "Node Version"
default: "20.x"
+1 -1
View File
@@ -3,7 +3,7 @@ name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
- "*-rc[0-9]+"
permissions:
contents: write
+7 -3
View File
@@ -163,9 +163,13 @@ def create_app() -> FastAPI:
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
static_path = Path("lnbits", "static")
static = StaticFiles(directory=static_path)
app.mount("/static", static, name="static")
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
app.mount(
"/library",
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
name="library",
)
g().base_url = f"http://{settings.host}:{settings.port}"
+3 -3
View File
@@ -1,5 +1,5 @@
from time import time
from typing import Optional, Tuple
from typing import Any, Optional, Tuple
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
@@ -110,8 +110,7 @@ async def get_payments_paginated(
- complete | pending | failed | outgoing | incoming.
"""
values: dict = {
"wallet_id": wallet_id,
values: dict[str, Any] = {
"time": since,
}
clause: list[str] = []
@@ -120,6 +119,7 @@ async def get_payments_paginated(
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
clause.append("wallet_id = :wallet_id")
if complete and pending:
+5
View File
@@ -46,3 +46,8 @@ class SimpleItem(BaseModel):
class DbVersion(BaseModel):
db: str
version: int
class Image(BaseModel):
filename: str
directory: str = "library"
+2 -1
View File
@@ -29,7 +29,8 @@ NOTIFICATION_TEMPLATES = {
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.""",
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.""",
"server_start_stop": """*SERVER*
{message}
*Time*: `{up_time}` seconds.
+1 -1
View File
@@ -198,7 +198,7 @@ class CreateInvoice(BaseModel):
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = None
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
expiry: int | None = None
-2
View File
@@ -24,7 +24,6 @@ from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
@@ -58,7 +57,6 @@ __all__ = [
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
+39 -23
View File
@@ -24,7 +24,7 @@ from lnbits.core.models.notifications import (
)
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
@@ -111,41 +111,56 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
return response.json()
async def send_email_notification(message: str) -> dict:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
"LNbits Notification",
message,
)
return {"status": "ok"}
async def send_email_notification(
message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
try:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
subject,
message,
)
return {"status": "ok"}
except Exception as e:
logger.error(f"Error sending email notification: {e}")
return {"status": "error", "message": str(e)}
async def send_email(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list,
to_emails: list[str],
subject: str,
message: str,
):
) -> bool:
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if len(to_emails) == 0:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
try:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(from_email, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
except Exception as e:
logger.debug(f"Failed to send email: {e}")
username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
@@ -246,7 +261,8 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
)
+1 -1
View File
@@ -108,7 +108,7 @@ async def create_invoice(
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
+10 -13
View File
@@ -8,7 +8,6 @@ from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
@@ -47,7 +46,9 @@ async def create_user_account(
async def create_user_account_no_ckeck(
account: Optional[Account] = None, wallet_name: Optional[str] = None
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
) -> User:
if account:
@@ -70,9 +71,13 @@ async def create_user_account_no_ckeck(
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_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
for ext_id in user_extensions:
try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
@@ -154,14 +159,6 @@ async def check_admin_settings():
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
@@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -150,7 +150,6 @@
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
@@ -194,6 +193,24 @@
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_username')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_username_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_username"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
@@ -212,7 +229,17 @@
/>
</q-item-section>
</q-item>
<q-item>
<q-btn
@click="sendTestEmail()"
:label="$t('notifications_send_test_email')"
color="primary"
class="q-mt-md"
></q-btn>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
@@ -249,9 +276,6 @@
</div>
</q-item-section>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
+1 -12
View File
@@ -10,7 +10,7 @@
filled
type="text"
v-model="formData.lnbits_site_title"
:label="$t('ui_site_title')"
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
></q-input>
<br />
</div>
@@ -53,17 +53,6 @@
:label="$t('lnbits_wallet')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('denomination')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
:hint="$t('denomination_hint')"
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input
+10 -1
View File
@@ -149,6 +149,14 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
@@ -177,7 +185,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-form>
</template>
+47 -8
View File
@@ -85,7 +85,12 @@
<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
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -96,7 +101,8 @@
<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
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
@@ -112,7 +118,12 @@
<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
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -130,8 +141,9 @@
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -218,7 +230,12 @@
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -227,11 +244,33 @@
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
+5 -5
View File
@@ -45,7 +45,7 @@
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password_config')"></span>
<span v-text="$t('password')"></span>
</h4>
</div>
<div class="col">
@@ -102,7 +102,7 @@
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
:label="$t('update_password')"
>
</q-btn>
</q-card-section>
@@ -110,7 +110,7 @@
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
Nostr <span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
@@ -287,7 +287,7 @@
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
:label="$t('change_password')"
filled
color="primary"
class="float-right"
@@ -356,7 +356,7 @@
flat
@click="themeChoiceFunc('bitcoin')"
icon="circle"
color="orange"
color="deep-orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
@@ -38,7 +38,7 @@
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
+23 -17
View File
@@ -87,18 +87,20 @@
>
{{SITE_TITLE}}
</h5>
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
@@ -182,12 +184,13 @@
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
Aready have an account?
<span v-text="$t('existing_account_question')"></span>
<span
class="text-secondary cursor-pointer"
@click="showLogin('username-password')"
>Login</span
>
v-text="$t('login')"
></span>
</p>
</div>
</username-password>
@@ -223,7 +226,7 @@
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
@@ -279,7 +282,10 @@
</div>
</div>
{% endif %}
<div v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'" class="full-width">
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
+16 -9
View File
@@ -59,9 +59,11 @@
<div class="col-7">
<div class="row">
<div class="col-auto">
<div class="text-h3 q-my-none text-no-wrap">
<div class="text-h3 q-my-none full-width">
<strong
v-text="walletFormatBalance(this.g.wallet.sat)"
class="text-no-wrap"
:style="{fontSize: 'clamp(0.75rem, 10vw, 3rem)', display: 'inline-block', maxWidth: '100%'}"
></strong>
</div>
</div>
@@ -166,7 +168,12 @@
@click="showReceiveDialog"
:label="$t('create_invoice')"
></q-btn>
<q-btn unelevated color="secondary" icon="qr_code_scanner">
<q-btn
unelevated
color="secondary"
icon="qr_code_scanner"
@click="showCamera"
>
<q-tooltip
><span v-text="$t('camera_tooltip')"></span
></q-tooltip>
@@ -726,6 +733,13 @@
</div>
<div v-if="g.fiatTracking">
<div v-if="isFiatPriority">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
<div v-else style="opacity: 0.75">
<div class="text-h5 text-italic">
<span
v-text="parse.invoice.fiatAmount"
@@ -733,13 +747,6 @@
></span>
</div>
</div>
<div v-else style="opacity: 0.75">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
</div>
</div>
<q-separator></q-separator>
+11 -1
View File
@@ -127,7 +127,17 @@
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
+112 -3
View File
@@ -1,23 +1,29 @@
import os
import time
from http import HTTPStatus
from shutil import make_archive
from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen
from typing import Optional
from tempfile import NamedTemporaryFile
from typing import IO, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_notification,
get_balance_delta,
update_cached_settings,
)
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
@@ -26,6 +32,7 @@ from .. import core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
file_upload = File(...)
@admin_router.get(
@@ -50,6 +57,18 @@ async def api_monitor():
}
@admin_router.get(
"/api/v1/testemail",
name="TestEmail",
description="send a test email to the admin",
dependencies=[Depends(check_admin)],
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
)
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
@@ -146,3 +165,93 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+1 -2
View File
@@ -1,5 +1,4 @@
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlencode, urlparse
@@ -37,7 +36,7 @@ generic_router = APIRouter(
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return FileResponse(Path("lnbits", "static", "favicon.ico"))
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
+20 -3
View File
@@ -104,16 +104,24 @@ async def api_create_user(data: CreateUser) -> CreateUser:
)
account.validate_fields()
account.hash_password(data.password)
user = await create_user_account_no_ckeck(account)
user = await create_user_account_no_ckeck(account, default_exts=data.extensions)
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:
async def api_update_user(
user_id: str, data: CreateUser, user: User = Depends(check_admin)
) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if data.password or data.password_repeat:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
@@ -255,13 +263,22 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
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:
async def api_users_delete_user_wallet(
user_id: str, wallet: str, user: User = Depends(check_admin)
) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
+12 -4
View File
@@ -5,7 +5,7 @@ import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security import APIKeyHeader, APIKeyQuery, HTTPBearer, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from loguru import logger
from pydantic.types import UUID4
@@ -31,8 +31,15 @@ from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import path_segments
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth",
auto_error=False,
description="OAuth2 access token for authentication with username and password.",
)
http_bearer = HTTPBearer(
auto_error=False,
description="Bearer Token for custom ACL based access control",
)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
@@ -132,8 +139,9 @@ async def require_invoice_key(
async def check_access_token(
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
) -> Optional[str]:
return header_access_token or cookie_access_token
return header_access_token or cookie_access_token or bearer_access_token
async def check_user_exists(
+14 -4
View File
@@ -10,7 +10,6 @@ from urllib.parse import urlparse
import jinja2
import jwt
import shortuuid
from fastapi import Request
from fastapi.routing import APIRoute
from packaging import version
from pydantic.schema import field_schema
@@ -72,7 +71,11 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
t.env.globals["VOIDWALLET"] = settings.lnbits_backend_wallet_class == "VoidWallet"
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
t.env.globals["LNBITS_DENOMINATION"] = (
settings.lnbits_denomination
if settings.lnbits_backend_wallet_class == "FakeWallet"
else "sats"
)
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
@@ -346,5 +349,12 @@ def normalize_path(path: Optional[str]) -> str:
return "/" + "/".join(path_segments(path))
def normalized_path(request: Request) -> str:
return "/" + "/".join(path_segments(request.url.path))
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
image_folder = Path(settings.lnbits_data_folder, directory)
file_path = image_folder / filename
# Prevent dir traversal attack
if image_folder.resolve() not in file_path.resolve().parents:
raise ValueError("Unsafe filename.")
# Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1]
return file_path.resolve()
-477
View File
@@ -1,477 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import json
from http import HTTPStatus
from typing import TYPE_CHECKING
from fastapi import HTTPException
from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodeInvoiceFilters,
NodePayment,
NodePaymentsFilters,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.utils.cache import cache
if TYPE_CHECKING:
from lnbits.wallets import PhoenixdWallet
def msat(raw: str) -> int:
return int(raw) * 1000
def _decode_bytes(data: str) -> str:
return base64.b64decode(data).hex()
def _encode_bytes(data: str) -> str:
return base64.b64encode(bytes.fromhex(data)).decode()
def _encode_urlsafe_bytes(data: str) -> str:
return base64.urlsafe_b64encode(bytes.fromhex(data)).decode()
def _parse_channel_point(raw: str) -> ChannelPoint:
funding_tx, output_index = raw.split(":")
return ChannelPoint(
funding_txid=funding_tx,
output_index=int(output_index),
)
class PhoenixdNode(Node):
wallet: PhoenixdWallet
async def request(self, method: str, path: str, json: dict | None = None, **kwargs):
response = await self.wallet.client.request(
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
)
try:
response.raise_for_status()
except HTTPStatusError as exc:
json = exc.response.json()
if json:
error = json.get("error") or json
raise HTTPException(
exc.response.status_code, detail=error.get("message")
) from exc
return response.json()
def get(self, path: str, **kwargs):
return self.request("GET", path, **kwargs)
async def _get_id(self) -> str:
info = await self.get("/v1/getinfo")
return info["identity_pubkey"]
async def get_peer_ids(self) -> list[str]:
response = await self.get("/v1/peers")
return [p["pub_key"] for p in response["peers"]]
async def connect_peer(self, uri: str):
try:
pubkey, host = uri.split("@")
except ValueError as exc:
raise HTTPException(400, detail="Invalid peer URI") from exc
await self.request(
"POST",
"/v1/peers",
json={
"addr": {"pubkey": pubkey, "host": host},
"perm": True,
"timeout": 30,
},
)
async def disconnect_peer(self, peer_id: str):
try:
await self.request("DELETE", "/v1/peers/" + peer_id)
except HTTPException as exc:
if "unable to disconnect" in exc.detail:
raise HTTPException(
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
) from exc
raise
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
try:
response = await self.get("/v1/graph/node/" + peer_id)
except HTTPException:
return NodePeerInfo(id=peer_id)
node = response["node"]
return NodePeerInfo(
id=peer_id,
alias=node["alias"],
color=node["color"].strip("#"),
last_timestamp=node["last_update"],
addresses=[a["addr"] for a in node["addresses"]],
)
async def open_channel(
self,
peer_id: str,
local_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
response = await self.request(
"POST",
"/v1/channels",
json={
"node_pubkey": _encode_bytes(peer_id),
"sat_per_vbyte": fee_rate,
"local_funding_amount": local_amount,
"push_sat": push_amount,
},
)
return ChannelPoint(
# WHY IS THIS REVERSED?!
funding_txid=bytes(
reversed(base64.b64decode(response["funding_txid_bytes"]))
).hex(),
output_index=response["output_index"],
)
async def _close_channel(
self,
point: ChannelPoint,
force: bool = False,
):
async with self.wallet.client.stream(
"DELETE",
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
params={"force": force},
timeout=None,
) as stream:
async for chunk in stream.aiter_text():
if not chunk:
continue
chunk = json.loads(chunk)
if "error" in chunk:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=chunk["error"].get("message"),
)
logger.info(f"LND Channel close update: {chunk.get('result')}")
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
):
if short_id:
logger.debug(f"Closing channel with short_id: {short_id}")
if not point:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
)
asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006
async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int):
# https://lightning.engineering/api-docs/api/lnd/lightning/update-channel-policy/
channel = await self.get_channel(channel_id)
if not channel:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Channel not found"
)
if not channel.point:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
)
await self.request(
"POST",
"/v1/chanpolicy",
json={
"base_fee_msat": base_msat,
"fee_rate_ppm": ppm,
"chan_point": {
"funding_txid_str": channel.point.funding_txid,
"output_index": channel.point.output_index,
},
# https://docs.lightning.engineering/lightning-network-tools/lnd/optimal-configuration-of-a-routing-node#channel-defaults
"time_lock_delta": 80,
# 'max_htlc_msat': <uint64>,
# 'min_htlc_msat': <uint64>,
# 'inbound_fee': <InboundFee>,
},
)
async def get_channel(self, channel_id: str) -> NodeChannel | None:
channel_info = await self.get(f"/v1/graph/edge/{channel_id}")
info = await self.get("/v1/getinfo")
if info["identity_pubkey"] == channel_info["node1_pub"]:
my_node_key = "node1"
peer_node_key = "node2"
else:
my_node_key = "node2"
peer_node_key = "node1"
peer_id = channel_info[f"{peer_node_key}_pub"]
peer_b64 = _encode_urlsafe_bytes(peer_id)
channels = await self.get(f"/v1/channels?peer={peer_b64}")
if "error" in channel_info and "error" in channels:
logger.debug("LND get_channel", channels)
return None
if len(channels["channels"]) == 0:
logger.debug(f"LND get_channel no channels founds with id {peer_b64}")
return None
for channel in channels["channels"]:
if channel["chan_id"] == channel_id:
peer_info = await self.get_peer_info(peer_id)
return NodeChannel(
id=channel.get("chan_id"),
peer_id=peer_info.id,
name=peer_info.alias,
color=peer_info.color,
state=(
ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE
),
fee_ppm=channel_info[f"{my_node_key}_policy"][
"fee_rate_milli_msat"
],
fee_base_msat=channel_info[f"{my_node_key}_policy"][
"fee_base_msat"
],
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
)
return None
async def get_channels(self) -> list[NodeChannel]:
normal, pending, closed = await asyncio.gather(
self.get("/v1/channels"),
self.get("/v1/channels/pending"),
self.get("/v1/channels/closed"),
)
channels = []
async def parse_pending(raw_channels, state):
for channel in raw_channels:
channel = channel["channel"]
info = await self.get_peer_info(channel["remote_node_pub"])
channels.append(
NodeChannel(
peer_id=info.id,
state=state,
name=info.alias,
color=info.color,
id=channel.get("chan_id", "node is for pending channels"),
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
)
)
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
await parse_pending(
pending["pending_force_closing_channels"], ChannelState.CLOSED
)
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
for channel in closed["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
id=channel.get("chan_id", "node is for closing channels"),
peer_id=info.id,
state=ChannelState.CLOSED,
name=info.alias,
color=info.color,
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=0,
remote_msat=0,
total_msat=msat(channel["capacity"]),
),
)
)
for channel in normal["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
id=channel["chan_id"],
short_id=channel["chan_id"],
point=_parse_channel_point(channel["channel_point"]),
peer_id=channel["remote_pubkey"],
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
state=(
ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE
),
# name=channel['peer_alias'],
name=info.alias,
color=info.color,
)
)
return channels
async def get_public_info(self) -> PublicNodeInfo:
info = await self.get("/v1/getinfo")
channels = await self.get_channels()
return PublicNodeInfo(
backend_name="LND",
id=info["identity_pubkey"],
color=info["color"].lstrip("#"),
alias=info["alias"],
num_peers=info["num_peers"],
blockheight=info["block_height"],
addresses=info["uris"],
channel_stats=ChannelStats.from_list(channels),
)
async def get_info(self) -> NodeInfoResponse:
public = await self.get_public_info()
onchain = await self.get("/v1/balance/blockchain")
fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels")
return NodeInfoResponse(
**public.dict(),
onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"],
fees=NodeFees(
total_msat=0,
daily_msat=fee_report["day_fee_sum"],
weekly_msat=fee_report["week_fee_sum"],
monthly_msat=fee_report["month_fee_sum"],
),
)
async def get_payments(
self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]:
count_key = "node:payments_count"
payments_count = cache.get(count_key)
if not payments_count and filters.offset:
# this forces fetching the payments count
await self.get_payments(Filters(limit=1))
payments_count = cache.get(count_key)
if filters.offset and payments_count:
index_offset = max(payments_count + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/payments",
params={
"index_offset": index_offset,
"max_payments": filters.limit,
"include_incomplete": True,
"reversed": True,
"count_total_payments": not index_offset,
},
)
if not filters.offset:
payments_count = int(response["total_num_payments"])
cache.set(count_key, payments_count)
payments = [
NodePayment(
payment_hash=payment["payment_hash"],
pending=payment["status"] == "IN_FLIGHT",
amount=payment["value_msat"],
fee=payment["fee_msat"],
time=payment["creation_date"],
destination=(
await self.get_peer_info(
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
)
if payment["htlcs"]
else None
),
bolt11=payment["payment_request"],
preimage=payment["payment_preimage"],
)
for payment in response["payments"]
]
payments.sort(key=lambda p: p.time, reverse=True)
return Page(data=payments, total=payments_count or 0)
async def get_invoices(
self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]:
last_invoice_key = "node:last_invoice_index"
last_invoice_index = cache.get(last_invoice_key)
if not last_invoice_index and filters.offset:
# this forces fetching the last invoice index so
await self.get_invoices(Filters(limit=1))
last_invoice_index = cache.get(last_invoice_key)
if filters.offset and last_invoice_index:
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/invoices",
params={
"index_offset": index_offset,
"num_max_invoices": filters.limit,
"reversed": True,
},
)
if not filters.offset:
last_invoice_index = int(response["last_index_offset"])
cache.set(last_invoice_key, last_invoice_index)
invoices = [
NodeInvoice(
payment_hash=_decode_bytes(invoice["r_hash"]),
amount=invoice["value_msat"],
memo=invoice["memo"],
pending=invoice["state"] == "OPEN",
paid_at=invoice["settle_date"],
expiry=int(invoice["creation_date"]) + int(invoice["expiry"]),
preimage=_decode_bytes(invoice["r_preimage"]),
bolt11=invoice["payment_request"],
)
for invoice in reversed(response["invoices"])
]
return Page(
data=invoices,
total=last_invoice_index or 0,
)
+74 -69
View File
@@ -4,16 +4,16 @@ import importlib
import importlib.metadata
import inspect
import json
import os
import re
from datetime import datetime, timezone
from enum import Enum
from hashlib import sha256
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
@@ -260,7 +260,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
@@ -271,13 +271,29 @@ 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")
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
lnbits_upload_allowed_types: list[str] = Field(
default=[
"image/png",
"image/jpeg",
"image/jpg",
"image/heic",
"image/heif",
"image/heics",
"png",
"jpeg",
"jpg",
"heic",
"heif",
"heics",
]
)
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_reserve_fee_min: int = Field(default=2000, ge=0)
lnbits_reserve_fee_percent: float = Field(default=1.0, ge=0)
lnbits_service_fee: float = Field(default=0, ge=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: str | None = Field(default=None)
@@ -293,9 +309,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=30)
lnbits_exchange_history_size: int = Field(default=60)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300)
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -360,7 +376,7 @@ class ExchangeProvidersSettings(LNbitsSettings):
class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_no: int = Field(default=200, ge=0)
lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[])
@@ -368,16 +384,16 @@ class SecuritySettings(LNbitsSettings):
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
)
lnbits_wallet_limit_max_balance: int = Field(default=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0, ge=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0, ge=0)
lnbits_only_allow_incoming_payments: bool = Field(default=False)
lnbits_watchdog_switch_to_voidwallet: bool = Field(default=False)
lnbits_watchdog_interval_minutes: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000)
lnbits_watchdog_interval_minutes: int = Field(default=60, gt=0)
lnbits_watchdog_delta: int = Field(default=1_000_000, gt=0)
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000)
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
def is_wallet_max_balance_exceeded(self, amount):
return (
@@ -396,6 +412,7 @@ class NotificationsSettings(LNbitsSettings):
lnbits_telegram_notifications_chat_id: str = Field(default="")
lnbits_email_notifications_enabled: bool = Field(default=False)
lnbits_email_notifications_email: str = Field(default="")
lnbits_email_notifications_username: str = Field(default="")
lnbits_email_notifications_password: str = Field(default="")
lnbits_email_notifications_server: str = Field(default="smtp.protonmail.ch")
lnbits_email_notifications_port: int = Field(default=587)
@@ -406,13 +423,18 @@ class NotificationsSettings(LNbitsSettings):
notification_balance_delta_changed: bool = Field(default=True)
lnbits_notification_server_start_stop: bool = Field(default=True)
lnbits_notification_watchdog: bool = Field(default=False)
lnbits_notification_server_status_hours: int = Field(default=24)
lnbits_notification_incoming_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_outgoing_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
lnbits_notification_incoming_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
lnbits_notification_outgoing_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
lnbits_denomination: str = Field(default="sats")
class LNbitsFundingSource(LNbitsSettings):
@@ -535,7 +557,7 @@ class BoltzFundingSource(LNbitsSettings):
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600)
lightning_invoice_expiry: int = Field(default=3600, gt=0)
class FundingSourcesSettings(
@@ -562,7 +584,7 @@ class FundingSourcesSettings(
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status
# It will not fail the payment, it will make it return pending after the timeout
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5)
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
class WebPushSettings(LNbitsSettings):
@@ -601,7 +623,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
@@ -611,7 +633,7 @@ class AuthSettings(LNbitsSettings):
)
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120)
auth_credetials_update_threshold: int = Field(default=120, gt=0)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
@@ -643,7 +665,7 @@ 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_retention_days: int = Field(default=7, ge=0)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
@@ -780,7 +802,7 @@ class EnvSettings(LNbitsSettings):
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000)
port: int = Field(default=5000, gt=0)
forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
@@ -792,24 +814,27 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time())
cleanup_wallets_days: int = Field(default=90)
funding_source_max_retries: int = Field(default=4)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: str | None = Field(default=None)
lnbits_saas_secret: str | None = Field(default=None)
lnbits_saas_instance_id: str | None = Field(default=None)
def check_auth_secret_key(self):
if self.auth_secret_key:
return
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
auth_key_file = Path(settings.lnbits_data_folder, ".lnbits_auth_key")
if auth_key_file.is_file():
with open(auth_key_file) as file:
self.auth_secret_key = file.readline()
return
self.auth_secret_key = uuid4().hex
with open(auth_key_file, "w+") as file:
file.write(self.auth_secret_key)
class PersistenceSettings(LNbitsSettings):
@@ -861,6 +886,13 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_all_extensions_ids: set[str] = Field(default=[])
server_startup_time: int = Field(default=time())
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
@@ -869,7 +901,6 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
class ReadOnlySettings(
EnvSettings,
ExtensionsInstallSettings,
SaaSSettings,
PersistenceSettings,
SuperUserSettings,
):
@@ -948,31 +979,6 @@ def set_cli_settings(**kwargs):
setattr(settings, key, value)
def send_admin_user_to_saas():
if settings.lnbits_saas_callback:
with httpx.Client() as client:
headers = {
"Content-Type": "application/json; charset=utf-8",
"X-API-KEY": settings.lnbits_saas_secret,
}
payload = {
"instance_id": settings.lnbits_saas_instance_id,
"adminuser": settings.super_user,
}
try:
client.post(
settings.lnbits_saas_callback,
headers=headers,
json=payload,
)
logger.success("sent super_user to saas application")
except Exception as e:
logger.error(
"error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {e!s}"
)
readonly_variables = ReadOnlySettings.readonly_fields()
transient_variables = TransientSettings.readonly_fields()
@@ -981,9 +987,8 @@ settings = Settings()
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
settings.version = importlib.metadata.version("lnbits")
settings.auth_secret_key = (
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
)
settings.check_auth_secret_key()
if not settings.user_agent:
settings.user_agent = f"LNbits/{settings.version}"
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7 -5
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -110,16 +110,16 @@ body[data-theme=bitcoin].body--light {
}
[data-theme=bitcoin] .bg-primary {
background: #ff9853 !important;
background: #ea611d !important;
}
[data-theme=bitcoin] .text-primary {
color: #ff9853 !important;
color: #ea611d !important;
}
[data-theme=bitcoin] .bg-secondary {
background: #ff7353 !important;
background: #e56f35 !important;
}
[data-theme=bitcoin] .text-secondary {
color: #ff7353 !important;
color: #e56f35 !important;
}
[data-theme=bitcoin] .bg-dark {
background: #2d293b !important;
@@ -134,10 +134,10 @@ body[data-theme=bitcoin].body--light {
color: #333646 !important;
}
[data-theme=bitcoin] .bg-marginal-bg {
background: #2d293b !important;
background: #000000 !important;
}
[data-theme=bitcoin] .text-marginal-bg {
color: #2d293b !important;
color: #000000 !important;
}
[data-theme=bitcoin] .bg-marginal-text {
background: #fff !important;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

+23 -12
View File
@@ -27,6 +27,7 @@ window.localisation.en = {
close_channel: 'Close Channel',
close: 'Close',
restart: 'Restart server',
image_library: 'Image Library',
save: 'Save',
save_tooltip: 'Save your changes',
credit_debit: 'Credit / Debit',
@@ -64,9 +65,9 @@ window.localisation.en = {
donate: 'Donate',
view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled',
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_badge: 'Service fee: {amount} % per transaction',
service_fee_max_badge:
'Service fee: {amount} % per transaction (max {max} {denom})',
service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode',
@@ -116,7 +117,7 @@ window.localisation.en = {
copy_wallet_url: 'Copy wallet URL',
disclaimer_dialog_title: 'Important!',
disclaimer_dialog:
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nLNbits holds no responsibility for loss of access to funds.',
no_transactions: 'No transactions made yet',
manage: 'Manage',
exchanges: 'Exchanges',
@@ -175,6 +176,7 @@ window.localisation.en = {
payment_proof: 'Payment Proof',
update: 'Update',
update_available: 'Update {version} available!',
funding_sources: 'Funding Sources',
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
@@ -198,9 +200,13 @@ window.localisation.en = {
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notfications over Email',
notifications_enable_email_desc: 'Send notifications over email',
notifications_send_test_email: 'Send test email',
notifications_send_email: 'Send email',
notifications_send_email_desc: 'Email you will send from',
notifications_send_email_username: 'Username',
notifications_send_email_username_desc:
'Username, will use the email if not set',
notifications_send_email_password: 'Send email password',
notifications_send_email_password_desc:
'Password for the email you will send from',
@@ -310,11 +316,13 @@ window.localisation.en = {
password: 'Password',
password_config: 'Password Config',
password_repeat: 'Password repeat',
update_password: 'Update Password',
change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
set_password: 'Set Password',
invalid_password: 'Password must have at least 8 characters',
invalid_password_repeat: 'Passwords do not match',
login: 'Login',
register: 'Register',
username: 'Username',
@@ -330,6 +338,7 @@ window.localisation.en = {
invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider',
my_account: 'My Account',
existing_account_question: 'Already have an account?',
background_image: 'Background Image',
back: 'Back',
logout: 'Logout',
@@ -488,9 +497,11 @@ window.localisation.en = {
'Disable Service Fee for Internal Lightning Payments',
ui_management: 'UI Management',
ui_site_title: 'Site Title',
ui_changing_remove_lnbits_elements:
' (changing will remove LNbits elements on the homepage and footer)',
ui_site_tagline: 'Site Tagline',
ui_elements_enable: 'Enable elements on homepage',
ui_elements_disable: 'Disable elements on homepage',
ui_elements_enable: 'Enable elements on homepage/footer',
ui_elements_disable: 'Disable elements on homepage/footer',
ui_toggle_elements_tip: "Remove homepage elements like 'runs on' etc",
ui_site_description: 'Site Description',
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
@@ -500,14 +511,13 @@ window.localisation.en = {
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code Logo',
ui_qr_code_logo_hint: 'URL to logo image in QR code',
ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: 'Image showed at homepage/login',
ui_custom_badge: 'Custom Badge',
ui_custom_badge_label:
"Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'",
ui_custom_badge_label: "Custom Badge 'USE WITH CAUTION'",
ui_custom_badge_color_label: 'Custom Badge Color',
themes: 'Themes',
themes_hint: 'Choose themes available for users',
@@ -560,5 +570,6 @@ window.localisation.en = {
view_list: 'View wallets as list',
view_column: 'View wallets as rows',
filter_payments: 'Filter payments',
filter_date: 'Filter by date'
filter_date: 'Filter by date',
websocket_example: 'Websocket example'
}
+94 -55
View File
@@ -5,8 +5,8 @@ window.localisation.fi = {
site_customisation: 'Sivuston kustomointi',
funding: 'Rahoitus',
users: 'Käyttäjät',
audit: 'Auditointi',
api_watch: 'Api Watch',
audit: 'Seuranta',
api_watch: 'API-seuranta',
apps: 'Sovellukset',
channels: 'Kanavat',
transactions: 'Tapahtumat',
@@ -27,6 +27,7 @@ window.localisation.fi = {
close_channel: 'Sulje kanava',
close: 'Sulje',
restart: 'Palvelimen uudelleen käynnistys',
image_library: 'Kuvakirjasto',
save: 'Tallenna',
save_tooltip: 'Tallenna muutokset',
credit_debit: 'Hyvitä / Veloita',
@@ -37,7 +38,7 @@ window.localisation.fi = {
'Virtuaalivarojen ({amount} sat) hyvitys-/veloitustapahtuma onnistui. Maksukyky riippuuu rahoituslähteen todellisista varoista.',
restart_tooltip: 'Uudelleenkäynnistä palvelu muutosten käyttöönottamiseksi',
add_funds_tooltip: 'Lisää varoja lompakkoon',
reset_defaults: 'Peruuta muutokset',
reset_defaults: 'Palauta oletusasetukset',
reset_defaults_tooltip:
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio',
@@ -65,8 +66,7 @@ window.localisation.fi = {
'Näyttää että sinulla on lunastamattomia bitcoin varoja, mutta sinulla ei vielä ole lompakkoa. Lunasta varat allaolevaa nappia painamalla, ja sinulle luodaan lompakko.',
donate: 'Lahjoita',
view_github: 'Näytä GitHub:ssa',
voidwallet_active:
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
voidwallet_active: 'VoidWallet on aktiivinen. Se ei tue maksutapahtumia!',
use_with_caution:
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: {name}',
service_fee_tooltip:
@@ -84,6 +84,7 @@ window.localisation.fi = {
create_invoice: 'Laskuta',
camera_tooltip: 'Kuvaa lasku tai QR-koodi',
export_csv: 'Vie CSV-tiedostoon',
export_csv_details: 'Vie CSV-tiedostoon lisätietoineen',
chart_tooltip: 'Näytä kaaviokuva',
pending: 'Odottaa',
copy_invoice: 'Kopioi lasku',
@@ -113,10 +114,10 @@ window.localisation.fi = {
copy_wallet_url: 'Kopioi lompakon URL',
disclaimer_dialog_title: 'Tärkeää!',
disclaimer_dialog:
'Sinun *PITÄÄ TALLETTAA* kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossa kirjautumaan lompakkoosi! Löydät kirjautumistiedot Tilin asetukset -sivulta. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
'Sinun *PITÄÄ TALLETTAA* kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossa kirjautumaan lompakkoosi! Löydät kirjautumistiedot Tilin asetukset -sivulta. Kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
manage: 'Hallinnointi',
exchanges: 'rssit',
exchanges: 'Vaihtokurssit',
extensions: 'Laajennukset',
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
@@ -124,7 +125,7 @@ window.localisation.fi = {
extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label:
'Lähde-URL (käytä vain virallista LNbits ja luotettavia laajennuslähteitä)',
'Lähde-URL (käytä vain virallista LNbits tai muuta luotettaa laajennuslähdettä)',
warning: 'Varoitus',
repository: 'Laajennuksien lähde',
confirm_continue: 'Haluatko varmasti jatkaa?',
@@ -168,11 +169,12 @@ window.localisation.fi = {
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanheneminen',
expiry: 'Vanhenee',
webhook: 'Webhook',
payment_proof: 'Maksun varmenne',
update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!',
update_available: 'Rahoituslähteet',
latest_update:
'Käytössä oleva versio {version}, on viimeisin saatavilla oleva.',
notifications: 'Tiedotteet',
@@ -182,7 +184,7 @@ window.localisation.fi = {
notifications_enable_nostr_desc: 'Lähetä tietodukset Nostr:in kautta',
notifications_nostr_private_key: 'Nostr-yksityisavain',
notifications_nostr_private_key_desc:
'Yksityinen avain (hex tai nsec) Nostr viestien lähettämisen allekirjoitukseen',
'Yksityinen avain (hex tai nsec) Nostr-viestien lähettämisen allekirjoitukseen',
notifications_nostr_identifiers: 'Nostr-tunnisteet',
notifications_nostr_identifiers_desc:
'Lista tunnisteista kenelle tiedotukset lähetetään',
@@ -195,6 +197,25 @@ window.localisation.fi = {
notifications_chat_id: 'Keskustelun tunnus',
notifications_chat_id_desc: 'Keskustelun tunnus minne tiedotukset lähetetään',
notifications_email_config: 'Sähköposti määritykset',
notifications_enable_email: 'Käytä sähköpostia',
notifications_enable_email_desc: 'Lähetä tiedotteet sähköpostilla',
notifications_send_test_email: 'Lähetä testiposti',
notifications_send_email: 'Lähetä sähköpostiosoitteella',
notifications_send_email_desc: 'Lähettäjänä näkyvä sähköpostiosoite',
notifications_send_email_username: 'Käyttäjätunnus',
notifications_send_email_username_desc:
'Käyttäjätunnus, mikäli tyhjä, käytetään sähköpostiosoitetta',
notifications_send_email_password: 'Lähtevän sähköpostin salasana',
notifications_send_email_password_desc: 'Salasana lähettävälle sähköpostille',
notifications_send_email_server_port: 'Lähtevän sähköpostin SMTP-portti',
notifications_send_email_server_port_desc: 'SMTP-palvelimen portti',
notifications_send_email_server: 'Lähtevän sähköpostin SMTP-palvelin',
notifications_send_email_server_desc:
'SMTP-palvelin jonka kautta sähköpostit lähetetään',
notifications_send_to_emails: 'Sähköpostien vastaanottaja',
notifications_send_to_emails_desc: 'Kenelle sähköpostit lähetetään',
notification_settings_update: 'Asetuksia päivitetty',
notification_settings_update_desc:
'Tiedota kun palvelimen asetuksia on päivitetty',
@@ -213,15 +234,15 @@ window.localisation.fi = {
notification_incoming_payment: 'Saapuvat maksut',
notification_incoming_payment_desc:
'Tiedota kun lompakko vastaanottaa ja saapuvan maksun määrä ylittää rajan (sat)',
'Tiedota kun lompakon vastaanottaman ja saapuvan maksun määrä ylittää rajan (sat)',
notification_outgoing_payment: 'Lähtevät maksut',
notification_outgoing_payment_desc:
'Tiedota kun lompakko lähettää ja lähtevän maksun määrä ylittää rajan (sat)',
'Tiedota kun lompakon lähettävän ja maksettavan maksun määrä ylittää rajan (sat)',
notification_credit_debit: 'Hyvitä / Veloita',
notification_credit_debit: 'Hyvitys / Veloitus',
notification_credit_debit_desc:
'Tiedota kun lompakkoa Superuser hyvittää tai veloittaa lompakkoa',
'Tiedota kun Superuser tekee lompakon hyvitys- tai veloitustapahtumia',
notification_balance_delta_changed: 'Saldon määrän muutos',
notification_balance_delta_changed_desc:
@@ -235,7 +256,7 @@ window.localisation.fi = {
'Tällä määritetään kuinka usein taustatoiminto tarkistaa varojen Delta-muutokset [node_balance - lnbits_balance] killswitch-signaalille. Hakujen väli ilmoitetaan minuutteina.',
watchdog_delta: 'Watchdog Delta',
watchdog_delta_desc:
'Mikäli rahoituslähteen saldo laskee alle LNbits kokonaissaldon, muutetaan Rahoituslähteeksi muutetaan heti VoidWallet. Päivittämisen jälkeen asetus pitää päivittää manuaalisestsi.',
'Mikäli rahoituslähteen saldo laskee alle LNbits kokonaissaldon, muutetaan rahoituslähteeksi heti VoidWallet. Päivittämisen jälkeen asetus pitää päivittää manuaalisestsi.',
status: 'Tilanne',
notification_source: 'Tiedotteiden lähde',
notification_source_label:
@@ -245,38 +266,38 @@ window.localisation.fi = {
releases: 'Julkaisut',
watchdog: 'Watchdog',
server_logs: 'Palvelimen lokit',
ip_blocker: 'IP-suodatin',
ip_blocker: 'Palvelimen suojaus IP-osoitesuodattimella',
security: 'Turvallisuus',
security_tools: 'Turvallisuus työkalut',
block_access_hint: 'Estä pääsy IP-osoitteen perusteella',
allow_access_hint: 'Salli pääsy IP-osoitteen perusteella (ohittaa estot)',
enter_ip: 'Anna IP ja paina +',
rate_limiter: 'Toiston rajoitin',
callback_url_rules: 'Callback URL säännöt',
enter_callback_url_rule: 'Anna URL säätö regex-muodossa ja paina enter',
callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint:
'Callback URL:it (kuten LNURL) tarkistetaan näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
wallet_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
wallet_limit_max_withdraw_per_day:
'Päivittäinen lompakosta nostettavissa oleva määrä satosheissa (0 poistaa käytöstä)',
wallet_max_ballance: 'Lompakon maksimisaldo satosheina (0 poistaa käytöstä)',
'Päivittäin nostettavissa sat maksimi (0 poistaa käytöstä)',
wallet_max_ballance: 'Maksimisaldo (sat) (0 poistaa käytöstä)',
wallet_limit_secs_between_trans:
'Min sekuntia transaktioiden välillä lompakkoa kohden (0 poistaa käytöstä)',
only_incoming_payments_allowed: 'Only incoming payments allowed',
disable_outgoing_payments: 'Disable outgoing payments',
'Tapahtumien välinen minimi (sec) (0 poistaa käytöstä)',
only_incoming_payments_allowed: 'Vain saapuvat maksut sallittuna',
disable_outgoing_payments: 'Poista lähtevät maksut käytöstä',
number_of_requests: 'Pyyntöjen lukumäärä',
time_unit: 'aikayksikkö',
minute: 'minuutti',
settings: 'Settings',
settings: 'Asetukset',
second: 'sekunti',
hour: 'tunti',
disable_server_log: 'Poista palvelimen loki käytöstä',
enable_server_log: 'Ota palvelimen loki käyttöön',
disable_server_log: 'Piilota palvelimen loki',
enable_server_log: 'Näytä palvelimen loki',
coming_soon: 'Ominaisuus on tulossa pian',
session_has_expired: 'Käyttämätön sessio on vanhentunut. Kirjaudu uudelleen.',
instant_access_question: 'tai kirjaudu suoraan',
instant_access_question: 'perinteinen kirjautuminen',
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
@@ -293,11 +314,13 @@ window.localisation.fi = {
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
password_repeat: 'Toista uusi salasana',
update_password: 'Päivitä salasana',
change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain',
set_password: 'Aseta salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
invalid_password_repeat: 'Salasanat eivät täsmää',
login: 'Kirjaudu',
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
@@ -312,7 +335,8 @@ window.localisation.fi = {
update_account: 'Päivitä tiliä',
invalid_username: 'Virheellinen käyttäjänimi',
auth_provider: 'Tunnistamisen toimittaja',
my_account: 'Minun tili',
my_account: 'Tilini',
existing_account_question: 'Onkohan sinulla jo tili?',
background_image: 'Taustakuva',
back: 'Takaisin',
logout: 'Poistu',
@@ -358,7 +382,7 @@ window.localisation.fi = {
welcome_lnbits: 'Tervetuloa LNbits-palveluun',
setup_su_account: 'Määritä Superuser-tili alta.',
create_ticker_converter: 'Luo valuuttamuuntimen Ticker',
enable_audit: 'Ota auditointi käyttöön',
enable_audit: 'Ota seuranta käyttöön',
recommended: 'Suositeltu',
audit_desc: 'Tallenna HTTP-pyyntöjä seuraavien suodattimien mukaisesti',
audit_record_req: 'Tallenna pyynnön Body',
@@ -366,16 +390,16 @@ window.localisation.fi = {
audit_record_req_warning_1:
'Luottamukselliset tiedot (kuten salasanat) tallennetaan.',
audit_record_req_warning_2: 'Body-datamäätä voi olla iso.',
audit_record_use: 'Käytä sitä varoen.',
audit_record_use: 'Käytä varoen!',
audit_ip: 'Tallenna IP-osoite',
audit_ip_desc: 'Tallenna asiakkaan IP-osoite',
audit_path_params: 'Tallenna Path-parametrit',
audit_query_params: 'Tallenna Query-parametrit',
audit_http_methods: 'Sisällytä HTTP-menetelmät',
audit_http_methods: 'Tallenna HTTP-menetelmät',
audit_http_methods_hint:
'Luettelo mukaan otettavista HTTP-menetelmistä. Tyhjä luettelo tallettaa kaikki.',
audit_http_methods_label: 'HTTP-metodit',
audit_resp_codes: 'Sisällytä HTTP-vastauskoodit',
audit_resp_codes: 'Tallenna HTTP-vastauskoodit',
audit_resp_codes_hint:
'HTTP-koodien lista, jotka sisällytetään (regex-match). Tyhjä luettelo tallettaa kaikki. Esim: 4.*, 5.*',
audit_resp_codes_label: 'HTTP-vastauskoodi (säännöllinen lauseke)',
@@ -410,7 +434,7 @@ window.localisation.fi = {
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
node_managment: 'Solmun hallinta',
node_management_not_supported:
'Valittu rahoituslähde ei mahdollista solmun hallintaan.',
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
toggle_node_ui: 'Solmun käyttöliittymä',
toggle_public_node_ui: 'Julkinen näkymä solmun tietoihin',
toggle_transactions_node_ui:
@@ -418,17 +442,21 @@ window.localisation.fi = {
invoice_expiry: 'Laskun vanhenemisaika',
invoice_expiry_label: 'Laskun vanhentuminen (sekunteina)',
fee_reserve: 'Kuluvaraus',
fee_reserve_msats: 'Kuluvaraus milli-sat',
fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika',
payment_wait_time_desc:
'Kuinka pitkään maksua odotetaan saapuvaksi ennenkuin se merkitään Odotetaan -tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua tms',
server_management: 'Palvelimen hallinta',
base_url: 'Perus-URL',
base_url_label: 'Staattinen/pohjan URL palvelimelle',
authentication: 'Todennus',
auth_token_expiry_label: 'Tunnuksen vanhentumisaika minuutteina',
auth_token_expiry_hint: 'Aika minuuteissa, kunnes tunnus vanhenee',
base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL',
authentication: 'Käyttäjän todennus',
auth_token_expiry_label: 'Kirjautumisen vanhentumisaika minuutteina',
auth_token_expiry_hint: 'Aika minuuteissa, jossa kirjautuminen vanhenee',
auth_allowed_methods_label: 'Sallitut kirjautumismenetelmät',
auth_allowed_methods_hint: 'Valitse kirjautumismenetelmät',
auth_nostr_label: 'Nostr Request URL',
auth_nostr_label: 'Nostr kutsujen URL',
auth_nostr_hint:
'Asiakkaiden kirjautumiseen käyttämä absoluuttinen URL-osoite.',
auth_google_ci_label: 'Google-asiakastunnus',
@@ -446,27 +474,33 @@ window.localisation.fi = {
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint:
'Rajoita käytettävissä olevien fiat-valuuttojen määrää',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
service_fee: 'Palvelumaksu',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa',
max_outgoing_payment_amount: 'Lähtevän maksun enimmäismäärä',
max_outgoing_payment_amount_desc: 'Enimmäismäärä jonka voi maksaa',
service_fee: 'Palvelumaksut',
service_fee_label: 'Palvelumaksu (%)',
service_fee_hint: 'Tapahtumastakohtainen palvelumaksu (%)',
service_fee_max: 'Palvelumaksun enimmäismäärä',
service_fee_max_label: 'Palvelumaksu enintään (sat)',
service_fee_max_label: 'Palvelumaksu max (sat)',
service_fee_max_hint: 'Suurin veloitettava palvelumaksu (sat)',
fee_wallet: 'Palvelumaksujen lompakko',
fee_wallet_label: 'Palvelumaksujen lompakko (lompakon tunnus)',
fee_wallet_hint: 'Lompakon tunnus, johon lähetetään maksut lähtetetään',
fee_wallet_label: 'Palvelumaksujen tilityslompakko (lompakon tunnus)',
fee_wallet_hint: 'Lompakon tunnus, johon palvelumaksut tilitetään',
disable_fee: 'Poista maksu käytöstä',
disable_fee_internal: 'Poista palvelumaksu sisäisiltä maksuilta',
disable_fee_internal_desc: 'Poista palvelumaksu sisäisiltä salamamaksuilta',
disable_fee_internal_desc: 'Poista palvelumaksu sisäisiltä salamaksuilta',
ui_management: 'Käyttöliittymän hallinta',
ui_site_title: 'Sivuston nimi',
ui_changing_remove_lnbits_elements:
' (tämän muuttamalla LNbits elementit poistuvat kotisivulla ja alareunasta)',
ui_site_tagline: 'Sivuston iskulause',
ui_elements_enable: 'Ota käyttöön elementit etusivulla',
ui_elements_disable: 'Poista elementit käytöstä etusivulla',
ui_elements_enable: 'Ota käyttöön elementit etusivulla/alareunassa',
ui_elements_disable: 'Poista elementit käytöstä etusivulla/alareunassa',
ui_toggle_elements_tip: "Poista kotisivuelementit kuten 'toimii' jne.",
ui_site_description: 'Sivuston kuvaus',
ui_site_description_hint:
@@ -476,10 +510,11 @@ window.localisation.fi = {
lnbits_wallet: 'LNbits-lompakko',
denomination: 'Valuutan nimi',
denomination_hint: 'FakeWallet-lompakon valuutan nimi',
ui_qr_code_logo: 'QR-koodin logo',
denomination_error: 'Valuutta tunnisssa on oltava 3 merkkiä, tai `sat`',
ui_qr_code_logo: 'QR- ja Favicon-logo',
ui_qr_code_logo_hint:
'Anna QR-koodissa käytettävää logo-kuvaan osoittava URL',
ui_custom_image: 'Yksilöyty kuva',
'Anna QR-koodissa ja Faviconissa käytettävän logo-kuvan URL',
ui_custom_image: 'Yksilöity kuva',
ui_custom_image_label: 'Anna yksilöidyn kuvan URL-osoite',
ui_custom_image_hint:
'Yksilöity kuva näytetään aloitus- ja kirjautumissivuilla',
@@ -507,7 +542,8 @@ window.localisation.fi = {
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Salli uusien käyttäjien luominen etusivulla',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
new_user_not_allowed: 'Tunnusten luonti on estetty.',
components: 'Komponentit',
long_running_endpoints: 'Top 5 pisimpään yhteydessä ollutta päätepistettä',
http_request_methods: 'HTTP-pyynnön menetelmät',
@@ -535,6 +571,9 @@ window.localisation.fi = {
reset_wallet_keys: 'Uusi API-avaimet',
reset_wallet_keys_desc:
'Tämän lompakon API-avaimet uusitaan. Edelliset API-avaimet lakkaavat toimimasta ja uudet luodaan niiden tilalle..',
view_list: 'Näytä lompakot listana',
view_column: 'Näytä lompakot riveinä'
view_list: 'Näytä lompakot allekain',
view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket esimerkki'
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+85 -2
View File
@@ -4,6 +4,7 @@ window.AdminPageLogic = {
return {
settings: {},
logs: [],
library_images: [],
serverlogEnabled: false,
lnbits_theme_options: [
'classic',
@@ -140,6 +141,7 @@ window.AdminPageLogic = {
async created() {
await this.getSettings()
await this.getAudit()
await this.getUploadedImages()
this.balance = +'{{ balance|safe }}'
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
@@ -438,7 +440,7 @@ window.AdminPageLogic = {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
.then(response => {
Quasar.Notify.create({
this.$q.notify({
type: 'positive',
message: 'Success! Restarted Server',
icon: null
@@ -450,7 +452,29 @@ window.AdminPageLogic = {
formatDate(date) {
return moment(date * 1000).fromNow()
},
sendTestEmail() {
LNbits.api
.request(
'GET',
'/admin/api/v1/testemail',
this.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
this.$q.notify({
message: 'Test email sent!',
color: 'positive'
})
})
.catch(error => {
this.$q.notify({
message: error.message,
color: 'negative'
})
})
},
getAudit() {
LNbits.api
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
@@ -530,6 +554,65 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError)
})
},
onImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadImage(file)
}
},
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
LNbits.api
.request(
'POST',
'/admin/api/v1/images',
this.g.user.wallets[0].adminkey,
formData,
{headers: {'Content-Type': 'multipart/form-data'}}
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image uploaded!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
},
getUploadedImages() {
LNbits.api
.request('GET', '/admin/api/v1/images', this.g.user.wallets[0].inkey)
.then(response => {
this.library_images = response.data.map(image => ({
...image,
url: `${window.origin}/${image.directory}/${image.filename}`
}))
})
.catch(LNbits.utils.notifyApiError)
},
deleteImage(filename) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this image?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
`/admin/api/v1/images/${filename}`,
this.g.user.wallets[0].adminkey
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image deleted!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
})
},
downloadBackup() {
window.open('/admin/api/v1/backup', '_blank')
},
+2 -1
View File
@@ -209,7 +209,7 @@ window.LNbits = {
})
obj.walletOptions = obj.wallets.map(obj => {
return {
label: [obj.name, ' - ', obj.id].join(''),
label: [obj.name, ' - ', obj.id.substring(0, 5), '...'].join(''),
value: obj.id
}
})
@@ -843,6 +843,7 @@ window.windowMixin = {
? this.$q.localStorage.getItem('lnbits.darkMode')
: true
)
Chart.defaults.color = this.$q.dark.isActive ? '#fff' : '#000'
this.changeTheme(this.themeChoice)
this.applyBorder()
if (this.$q.dark.isActive) {
@@ -37,7 +37,8 @@ window.app.component('lnbits-funding-sources', {
'FakeWallet',
'Fake Wallet',
{
fake_wallet_secret: 'Secret'
fake_wallet_secret: 'Secret',
lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
}
],
[
@@ -178,6 +178,26 @@ window.app.component('payment-list', {
return LNbits.map.payment(obj)
})
})
.catch(err => {
this.paymentsTable.loading = false
if (g.user.admin) {
this.fetchPaymentsAsAdmin(this.currentWallet.id, params)
} else {
LNbits.utils.notifyApiError(err)
}
})
},
fetchPaymentsAsAdmin(walletId, params) {
params = (params || '') + '&wallet_id=' + walletId
return LNbits.api
.request('GET', '/api/v1/payments/all/paginated?' + params)
.then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total
this.payments = response.data.data.map(obj => {
return LNbits.map.payment(obj)
})
})
.catch(err => {
this.paymentsTable.loading = false
LNbits.utils.notifyApiError(err)
+8 -3
View File
@@ -329,7 +329,7 @@ window.UsersPageLogic = {
.catch(LNbits.utils.notifyApiError)
},
fetchWallets(userId) {
LNbits.api
return LNbits.api
.request('GET', `/users/api/v1/user/${userId}/wallet`)
.then(res => {
this.wallets = res.data
@@ -378,8 +378,13 @@ window.UsersPageLogic = {
this.activeUser.show = false
}
},
showPayments(wallet_id) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id)
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
await this.showPayments(walletId)
},
showPayments(walletId) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === walletId)
this.paymentPage.show = true
},
searchUserBy(fieldName) {
+1
View File
@@ -4,6 +4,7 @@ window.WalletPageLogic = {
return {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
parse: {
show: false,
invoice: null,
+3 -3
View File
@@ -8,11 +8,11 @@ $themes: (
marginal-text: #fff
),
'bitcoin': (
primary: #ff9853,
secondary: #ff7353,
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #2d293b,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
+45 -85
View File
@@ -1,4 +1,4 @@
// Axios v1.7.7 Copyright (c) 2024 Matt Zabriskie and contributors
/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
@@ -1266,23 +1266,6 @@
var toFiniteNumber = function toFiniteNumber(value, defaultValue) {
return value != null && Number.isFinite(value = +value) ? value : defaultValue;
};
var ALPHA = 'abcdefghijklmnopqrstuvwxyz';
var DIGIT = '0123456789';
var ALPHABET = {
DIGIT: DIGIT,
ALPHA: ALPHA,
ALPHA_DIGIT: ALPHA + ALPHA.toUpperCase() + DIGIT
};
var generateString = function generateString() {
var size = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 16;
var alphabet = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ALPHABET.ALPHA_DIGIT;
var str = '';
var length = alphabet.length;
while (size--) {
str += alphabet[Math.random() * length | 0];
}
return str;
};
/**
* If the thing is a FormData object, return true, otherwise return false.
@@ -1399,8 +1382,6 @@
findKey: findKey,
global: _global,
isContextDefined: isContextDefined,
ALPHABET: ALPHABET,
generateString: generateString,
isSpecCompliantForm: isSpecCompliantForm,
toJSONObject: toJSONObject,
isAsyncFn: isAsyncFn,
@@ -1735,7 +1716,7 @@
*
* @param {string} url The base of the url (e.g., http://www.google.com)
* @param {object} [params] The params to be appended
* @param {?object} options
* @param {?(object|Function)} options
*
* @returns {string} The formatted url
*/
@@ -1745,6 +1726,11 @@
return url;
}
var _encode = options && options.encode || encode;
if (utils$1.isFunction(options)) {
options = {
serialize: options
};
}
var serializeFn = options && options.serialize;
var serializedParams;
if (serializeFn) {
@@ -2642,60 +2628,14 @@
};
};
var isURLSameOrigin = platform.hasStandardBrowserEnv ?
// Standard browser envs have full support of the APIs needed to test
// whether the request URL is of the same origin as current location.
function standardBrowserEnv() {
var msie = platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent);
var urlParsingNode = document.createElement('a');
var originURL;
/**
* Parse a URL to discover its components
*
* @param {String} url The URL to be parsed
* @returns {Object}
*/
function resolveURL(url) {
var href = url;
if (msie) {
// IE needs attribute set twice to normalize properties
urlParsingNode.setAttribute('href', href);
href = urlParsingNode.href;
}
urlParsingNode.setAttribute('href', href);
// urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils
return {
href: urlParsingNode.href,
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
host: urlParsingNode.host,
search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '',
hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '',
hostname: urlParsingNode.hostname,
port: urlParsingNode.port,
pathname: urlParsingNode.pathname.charAt(0) === '/' ? urlParsingNode.pathname : '/' + urlParsingNode.pathname
};
}
originURL = resolveURL(window.location.href);
/**
* Determine if a URL shares the same origin as the current location
*
* @param {String} requestURL The URL to test
* @returns {boolean} True if URL shares the same origin, otherwise false
*/
return function isURLSameOrigin(requestURL) {
var parsed = utils$1.isString(requestURL) ? resolveURL(requestURL) : requestURL;
return parsed.protocol === originURL.protocol && parsed.host === originURL.host;
var isURLSameOrigin = platform.hasStandardBrowserEnv ? function (origin, isMSIE) {
return function (url) {
url = new URL(url, platform.origin);
return origin.protocol === url.protocol && origin.host === url.host && (isMSIE || origin.port === url.port);
};
}() :
// Non standard browser envs (web workers, react-native) lack needed support.
function nonStandardBrowserEnv() {
return function isURLSameOrigin() {
return true;
};
}();
}(new URL(platform.origin), platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent)) : function () {
return true;
};
var cookies = platform.hasStandardBrowserEnv ?
// Standard browser envs support document.cookie
@@ -2761,8 +2701,9 @@
*
* @returns {string} The combined full path
*/
function buildFullPath(baseURL, requestedURL) {
if (baseURL && !isAbsoluteURL(requestedURL)) {
function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) {
var isRelativeUrl = !isAbsoluteURL(requestedURL);
if (baseURL && isRelativeUrl || allowAbsoluteUrls == false) {
return combineURLs(baseURL, requestedURL);
}
return requestedURL;
@@ -2785,7 +2726,7 @@
// eslint-disable-next-line no-param-reassign
config2 = config2 || {};
var config = {};
function getMergedValue(target, source, caseless) {
function getMergedValue(target, source, prop, caseless) {
if (utils$1.isPlainObject(target) && utils$1.isPlainObject(source)) {
return utils$1.merge.call({
caseless: caseless
@@ -2799,11 +2740,11 @@
}
// eslint-disable-next-line consistent-return
function mergeDeepProperties(a, b, caseless) {
function mergeDeepProperties(a, b, prop, caseless) {
if (!utils$1.isUndefined(b)) {
return getMergedValue(a, b, caseless);
return getMergedValue(a, b, prop, caseless);
} else if (!utils$1.isUndefined(a)) {
return getMergedValue(undefined, a, caseless);
return getMergedValue(undefined, a, prop, caseless);
}
}
@@ -2860,8 +2801,8 @@
socketPath: defaultToConfig2,
responseEncoding: defaultToConfig2,
validateStatus: mergeDirectKeys,
headers: function headers(a, b) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), true);
headers: function headers(a, b, prop) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true);
}
};
utils$1.forEach(Object.keys(Object.assign({}, config1, config2)), function computeConfigValue(prop) {
@@ -3717,7 +3658,7 @@
});
}
var VERSION = "1.7.7";
var VERSION = "1.8.2";
var validators$1 = {};
@@ -3756,6 +3697,13 @@
return validator ? validator(value, opt, opts) : true;
};
};
validators$1.spelling = function spelling(correctSpelling) {
return function (value, opt) {
// eslint-disable-next-line no-console
console.warn("".concat(opt, " is likely a misspelling of ").concat(correctSpelling));
return true;
};
};
/**
* Assert object's properties type
@@ -3838,7 +3786,8 @@
_context.prev = 6;
_context.t0 = _context["catch"](0);
if (_context.t0 instanceof Error) {
Error.captureStackTrace ? Error.captureStackTrace(dummy = {}) : dummy = new Error();
dummy = {};
Error.captureStackTrace ? Error.captureStackTrace(dummy) : dummy = new Error();
// slice off the Error: ... line
stack = dummy.stack ? dummy.stack.replace(/^.+\n/, '') : '';
@@ -3901,6 +3850,17 @@
}
}
// Set config.allowAbsoluteUrls
if (config.allowAbsoluteUrls !== undefined) ; else if (this.defaults.allowAbsoluteUrls !== undefined) {
config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls;
} else {
config.allowAbsoluteUrls = true;
}
validator.assertOptions(config, {
baseUrl: validators.spelling('baseURL'),
withXsrfToken: validators.spelling('withXSRFToken')
}, true);
// Set config.method
config.method = (config.method || this.defaults.method || 'get').toLowerCase();
@@ -3968,7 +3928,7 @@
key: "getUri",
value: function getUri(config) {
config = mergeConfig(this.defaults, config);
var fullPath = buildFullPath(config.baseURL, config.url);
var fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls);
return buildURL(fullPath, config.params, config.paramsSerializer);
}
}]);
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -89,11 +89,11 @@
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
v-text='$t("service_fee_max_badge", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}", denom: "{{ LNBITS_DENOMINATION }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
v-text='$t("service_fee_badge", { amount: "{{ LNBITS_SERVICE_FEE}}"})'
></span>
{%endif%}
<q-tooltip
@@ -343,6 +343,7 @@
{% endblock %} {% block footer %}
<q-footer
v-if="'{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}"
>
+30 -9
View File
@@ -320,7 +320,26 @@
<q-item v-if="payment.preimage">
<q-item-section>
<q-item-label v-text="$t('payment_proof')"></q-item-label>
<q-item-label caption v-text="payment.preimage"></q-item-label>
<q-item-label
caption
v-text="
`${payment.preimage.slice(0, 12)}...${payment.preimage.slice(-12)}`
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-icon
name="content_copy"
@click="copyText(payment.preimage)"
size="1em"
color="grey"
class="cursor-pointer"
/>
</q-item-label>
<q-tooltip>
<span v-text="payment.preimage"></span>
</q-tooltip>
</q-item-section>
</q-item>
@@ -1057,7 +1076,16 @@
<template id="lnbits-funding-sources">
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('funding_sources')"></span>
<q-btn
round
flat
@click="this.hideInput = !this.hideInput"
:icon="this.hideInput ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
@@ -1095,13 +1123,6 @@
:label="prop.label"
:hint="prop.hint"
>
<template v-slot:append>
<q-icon
:name="hideInput ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="this.hideInput = !this.hideInput"
></q-icon>
</template>
</q-input>
</div>
</div>
+5 -5
View File
@@ -1,3 +1,4 @@
import asyncio
from typing import Optional
import httpx
@@ -212,7 +213,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(url, timeout=0.5)
r = await client.get(url, timeout=3)
r.raise_for_status()
if not provider.path:
@@ -230,11 +231,10 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return None
# OK to be in squence: fetch_price times out after 0.5 seconds
results = [
await fetch_price(provider)
for provider in settings.lnbits_exchange_rate_providers
calls = [
fetch_price(provider) for provider in settings.lnbits_exchange_rate_providers
]
results = await asyncio.gather(*calls)
return [r for r in results if r is not None]
+10 -6
View File
@@ -2,7 +2,7 @@ import asyncio
from datetime import datetime
from hashlib import sha256
from os import urandom
from typing import AsyncGenerator, Dict, Optional, Set
from typing import AsyncGenerator, Optional
from bolt11 import (
Bolt11,
@@ -34,8 +34,8 @@ 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.payment_secrets: dict[str, str] = {}
self.paid_invoices: set[str] = set()
self.secret = settings.fake_wallet_secret
self.privkey = fake_privkey(self.secret)
@@ -80,11 +80,12 @@ class FakeWallet(Wallet):
secret = urandom(32).hex()
tags.add(TagChar.payment_secret, secret)
payment_hash = sha256(secret.encode()).hexdigest()
preimage = urandom(32)
payment_hash = sha256(preimage).hexdigest()
tags.add(TagChar.payment_hash, payment_hash)
self.payment_secrets[payment_hash] = secret
self.payment_secrets[payment_hash] = preimage.hex()
bolt11 = Bolt11(
currency="bc",
@@ -96,7 +97,10 @@ class FakeWallet(Wallet):
payment_request = encode(bolt11, self.privkey)
return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=payment_request
ok=True,
checking_id=payment_hash,
payment_request=payment_request,
# preimage=preimage.hex(),
)
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
+4 -2
View File
@@ -87,13 +87,15 @@ class LNbitsWallet(Wallet):
r.raise_for_status()
data = r.json()
if r.is_error or "bolt11" not in data:
# Backwards compatibility for pre-v1 which used the key "payment_request"
payment_str = data.get("bolt11") or data.get("payment_request")
if r.is_error or not payment_str:
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"], payment_str, None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
+1 -1
View File
@@ -175,7 +175,7 @@ class PhoenixdWallet(Wallet):
return PaymentResponse(None, None, None, None, error_message)
checking_id = data["paymentHash"]
fee_msat = -int(data["routingFeeSat"])
fee_msat = -int(data["routingFeeSat"]) * 1000
preimage = data["paymentPreimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
+27 -22
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.7.7",
"axios": "^1.8.2",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"nostr-tools": "^2.7.2",
@@ -15,7 +15,7 @@
"showdown": "^2.1.0",
"underscore": "^1.13.7",
"vue": "3.5.8",
"vue-i18n": "^10.0.5",
"vue-i18n": "^10.0.6",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
@@ -72,12 +72,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.5.tgz",
"integrity": "sha512-F3snDTQs0MdvnnyzTDTVkOYVAZOE/MHwRvF7mn7Jw1yuih4NrFYLNYIymGlLmq4HU2iIdzYsZ7f47bOcwY73XQ==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.6.tgz",
"integrity": "sha512-/NINGvy7t8qSCyyuqMIPmHS6CBQjqPIPVOps0Rb7xWrwwkwHJKtahiFnW1HC4iQVhzoYwEW6Js0923zTScLDiA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "10.0.5",
"@intlify/shared": "10.0.5"
"@intlify/message-compiler": "10.0.6",
"@intlify/shared": "10.0.6"
},
"engines": {
"node": ">= 16"
@@ -87,11 +88,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.5.tgz",
"integrity": "sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.6.tgz",
"integrity": "sha512-QcUYprK+e4X2lU6eJDxLuf/mUtCuVPj2RFBoFRlJJxK3wskBejzlRvh1Q0lQCi9tDOnD4iUK1ftcGylE3X3idA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "10.0.5",
"@intlify/shared": "10.0.6",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -102,9 +104,10 @@
}
},
"node_modules/@intlify/shared": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.5.tgz",
"integrity": "sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.6.tgz",
"integrity": "sha512-2xqwm05YPpo7TM//+v0bzS0FWiTzsjpSMnWdt7ZXs5/ZfQIedSuBXIrskd8HZ7c/cZzo1G9ALHTksnv/74vk/Q==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@@ -399,9 +402,10 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@@ -1301,12 +1305,13 @@
}
},
"node_modules/vue-i18n": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.5.tgz",
"integrity": "sha512-9/gmDlCblz3i8ypu/afiIc/SUIfTTE1mr0mZhb9pk70xo2csHAM9mp2gdQ3KD2O0AM3Hz/5ypb+FycTj/lHlPQ==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.6.tgz",
"integrity": "sha512-pQPspK5H4srzlu+47+HEY2tmiY3GyYIvSPgSBdQaYVWv7t1zj1t9p1FvHlxBXyJ17t9stG/Vxj+pykrvPWBLeQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "10.0.5",
"@intlify/shared": "10.0.5",
"@intlify/core-base": "10.0.6",
"@intlify/shared": "10.0.6",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
+2 -2
View File
@@ -20,7 +20,7 @@
"sass": "^1.78.0"
},
"dependencies": {
"axios": "^1.7.7",
"axios": "^1.8.2",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"qrcode.vue": "^3.4.1",
@@ -29,7 +29,7 @@
"nostr-tools": "^2.7.2",
"underscore": "^1.13.7",
"vue": "3.5.8",
"vue-i18n": "^10.0.5",
"vue-i18n": "^10.0.6",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
Generated
+12 -1
View File
@@ -1083,6 +1083,17 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "filetype"
version = "1.2.0"
description = "Infer file type and MIME type of any file/buffer. No external dependencies."
optional = false
python-versions = "*"
files = [
{file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"},
{file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"},
]
[[package]]
name = "greenlet"
version = "3.0.3"
@@ -3403,4 +3414,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "~3.12 | ~3.11 | ~3.10"
content-hash = "96dd180aaa4fbfeb34fa6f9647c8684fce183a72b1b41d22101a9dd4b962fa2e"
content-hash = "f56154a228bfd11ca92c1818dd2b7d71ff67b218225103f4b701e6523ba499ed"
+4 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc8"
version = "1.0.0-rc10"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
@@ -63,6 +63,8 @@ breez-sdk = {version = "0.6.6", optional = true}
jsonpath-ng = "^1.7.0"
pynostr = "^0.6.2"
python-multipart = "^0.0.20"
filetype = "^1.2.0"
[tool.poetry.extras]
breez = ["breez-sdk"]
liquid = ["wallycore"]
@@ -145,6 +147,7 @@ module = [
"fastapi_sso.sso.*",
"json5.*",
"jsonpath_ng.*",
"filetype.*",
]
ignore_missing_imports = "True"
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
import pytest
from lnbits.helpers import safe_upload_file_path
from lnbits.settings import settings
@pytest.mark.parametrize(
"filepath",
[
"test.txt",
"test/test.txt",
"test/test/test.txt",
"test/../test.txt",
"*/test.txt",
"test/**/test.txt",
"./test.txt",
],
)
def test_safe_upload_file_path(filepath: str):
safe_path = safe_upload_file_path(filepath)
assert safe_path.name == "test.txt"
# check if subdirectories got removed
images_folder = Path(settings.lnbits_data_folder) / "images"
assert images_folder.resolve() / "test.txt" == safe_path
@pytest.mark.parametrize(
"filepath",
[
"../test.txt",
"test/../../test.txt",
"../../test.txt",
"test/../../../test.txt",
"../../../test.txt",
],
)
def test_unsafe_upload_file_path(filepath: str):
with pytest.raises(ValueError):
safe_upload_file_path(filepath)
@@ -1170,7 +1170,7 @@
"pending": false,
"failed": false,
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"fee_msat": 50,
"fee_msat": 30000,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
},
"mocks": {
@@ -1187,8 +1187,8 @@
"response": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"msatoshi": 21000,
"msatoshi_sent": 21050,
"msatoshi": 210000,
"msatoshi_sent": 240000,
"status": "paid"
}
}
@@ -1206,7 +1206,7 @@
"response": {
"payment_hash": "41UmpD0E6YVZTA36uEiBT1JLHHhlmOyaY77dstcmrJY=",
"payment_route": {
"total_fees_msat": 50
"total_fees_msat": 30000
},
"payment_preimage": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
@@ -1224,7 +1224,7 @@
"response_type": "json",
"response": {
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"fee": 50,
"fee": 30000,
"payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
@@ -1257,7 +1257,7 @@
{
"status": {
"type": "sent",
"feesPaid": -50,
"feesPaid": -30000,
"paymentPreimage": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
@@ -1287,7 +1287,7 @@
"paid": true,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"details": {
"fee": 50
"fee": 30000
}
}
}
@@ -1303,7 +1303,7 @@
"response_type": "json",
"response": {
"paymentHash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"routingFeeSat": -50,
"routingFeeSat": -30,
"paymentPreimage": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
@@ -1690,7 +1690,7 @@
"response_type": "json",
"response": {
"paymentHash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"routingFeeSat": -50
"routingFeeSat": -30
}
}
]